diff --git a/app/application.ml b/app/application.ml index 2df25487..a179c2eb 100644 --- a/app/application.ml +++ b/app/application.ml @@ -259,7 +259,11 @@ type cleanup = Discard_staged_file of Journal_asset_import.staged type cleanup_plan = cleanup Rrbvec.t type state = - { favorites : Journal_routes.Favorites.t + { resident_states : state Graph_drafts.t + ; graph_preview : string option + ; resident_replacement : string option + ; graph_preview_revision : int + ; favorites : Journal_routes.Favorites.t ; favorites_media_roots : string Rrbvec.t ; favorites_requests : Journal_graph_request.favorites_request list ; routes : Journal_routes.t @@ -405,7 +409,11 @@ let feed_root_limit = 30 let sync_error_card_lifetime = 5. let initial_state = - { favorites = Journal_routes.Favorites.create ~graph_generation:(-1) + { resident_states = Graph_drafts.empty + ; graph_preview = None + ; resident_replacement = None + ; graph_preview_revision = 0 + ; favorites = Journal_routes.Favorites.create ~graph_generation:(-1) ; favorites_media_roots = Rrbvec.empty ; favorites_requests = [] ; routes = Journal_routes.create () @@ -668,7 +676,8 @@ let discard_local_graph_state ~discard state = { (clear_graph_surface ~discard state) with draft_graph = None; graph_drafts } ;; -let clear_account_drafts ~discard state = +let rec clear_account_drafts ~discard state = + Graph_drafts.iter (fun _ saved->ignore(clear_account_drafts ~discard saved)) state.resident_states; Graph_drafts.iter (fun _ draft -> Option.iter @@ -684,6 +693,7 @@ let clear_account_drafts ~discard state = { (clear_graph_surface ~discard state) with draft_graph = None ; graph_drafts = Graph_drafts.empty + ; resident_states = Graph_drafts.empty ; worker_errors = [] ; manager_attention = None ; sync_error = None @@ -1632,6 +1642,7 @@ module Root_navigation = struct | Capture_edited of string | Capture_admitted of Journal_capture.t | Completed of Journal_graph_runtime.response + | Graph_focused of { generation : int; graph_id : Logseq_db_types.Graph_types.Uuid.t } | Graph_replaced of { generation : int ; graph_id : Logseq_db_types.Graph_types.Uuid.t option @@ -1679,6 +1690,16 @@ module Root_navigation = struct } ;; + let next_graph ~current order = + match order with + | [] | [_] -> None + | first::_ -> + let rec find = function + | []->None | [id] when id=current->Some first + | id::next::_ when id=current->Some next | _::rest->find rest in + find order + ;; + let diagnostic_rows state = application_error_rows state.worker_errors let create ~graph_generation = @@ -1860,10 +1881,26 @@ module Root_navigation = struct | Some previous when previous == capture -> state | _ -> { state with direct_capture = Some capture }) | Completed response -> apply_worker_response ~discard state response + | Graph_focused { generation; graph_id } -> + let key id generation=Logseq_db_types.Graph_types.Uuid.to_string id ^ ":" ^ string_of_int generation in + let states=match state.graph_state.graph_id with + | None -> state.resident_states + | Some id -> Graph_drafts.add (key id state.graph_state.generation) + {state with resident_states=Graph_drafts.empty;graph_preview=None} state.resident_states in + let retained=match Graph_drafts.find_opt (key graph_id generation) states with + | Some retained -> retained + | None -> let fresh=replace_graph ~discard {initial_state with graph_drafts=state.graph_drafts} generation (Some graph_id) in + {fresh with graph_state={fresh.graph_state with graph_id=Some graph_id}} in + {retained with resident_states=states;calendar=state.calendar;environment=state.environment; + manager=state.manager;graph_catalog=state.graph_catalog;local_copies=state.local_copies; + local_copy_check=state.local_copy_check;local_copy_deleting=state.local_copy_deleting; + local_copy_message=state.local_copy_message;graph_picker_open=state.graph_picker_open; + graph_preview=None;graph_preview_revision=state.graph_preview_revision+1} | Graph_replaced { generation; graph_id } -> let state = replace_graph ~discard state generation graph_id in { state with graph_state = { state.graph_state with graph_id } } - | Account_cleared -> clear_account_drafts ~discard state + | Account_cleared -> + { (clear_account_drafts ~discard state) with resident_states=Graph_drafts.empty;graph_preview=None } | Local_copy_deleted -> discard_local_graph_state ~discard state in track_capture_session state @@ -1914,6 +1951,13 @@ let import_failure_transition state batch = ;; type command = + | Remove_resident of string + | Move_resident of int * int + | Replace_resident of string + | Cancel_resident_replacement + | Graph_swipe_prepare + | Graph_swipe_commit + | Graph_swipe_cancel | Open_asset_settings | Capture_submit | Select_journals @@ -2731,7 +2775,11 @@ let manager_picker_live state : Journal_manager_view.picker_live = | None -> "") | None -> "") in let copies = state.local_copies in - { status + { residents=(match state.manager with None->[] | Some m-> + let ids=if m.resident_graphs=[] then Option.to_list m.selected_graph else m.resident_graphs in + List.map Logseq_db_types.Graph_types.Uuid.to_string ids) + ; replacement=state.resident_replacement + ; status ; authenticated = Option.exists (fun manager -> manager.Graph_service.startup.authenticated) state.manager ; checking = Option.is_some state.local_copy_check ; deleting = state.local_copy_deleting @@ -2839,6 +2887,10 @@ let manager_page ?picker_signal state dispatch = | Journal_manager_view.Startup_retry -> dispatch.send Startup_retry | Refresh_catalog -> dispatch.send Refresh_catalog | Begin_online_recovery -> dispatch.send Begin_online_recovery + | Remove_resident id -> dispatch.send (Remove_resident id) + | Move_resident (index,direction) -> dispatch.send (Move_resident (index,direction)) + | Replace_resident id -> dispatch.send (Replace_resident id) + | Cancel_replacement -> dispatch.send Cancel_resident_replacement | Select_graph id -> dispatch.send (Select_graph id) | Switch_graph -> dispatch.send Switch_graph | Close_picker -> dispatch.send Close_graph_picker @@ -2957,6 +3009,7 @@ let upload_context state = ;; type action = + | Graph_update of Graph_service.timeline_scope * (state -> state * unit Effect.t) | Update of (state -> state * unit Effect.t) | Platform_response of int * (bytes, string) result | Environment_changed of Journal_environment.snapshot @@ -3022,6 +3075,48 @@ let timeline_scope state : Graph_service.timeline_scope option = ({startup=startup_scope state;graph_id;graph_generation=m.startup.graph_generation} : Graph_service.timeline_scope)) m.selected_graph) ;; +let timeline_owner_of_scope (scope:Graph_service.asset_scope) : Graph_service.timeline_scope = + {startup={account_generation=scope.account.account_generation;presentation_generation=scope.account.presentation_generation}; + graph_id=scope.graph_id;graph_generation=scope.graph_generation} +let runtime_owner_is_live state (owner:Graph_service.timeline_scope) = + match state.manager with + | None -> true + | Some manager -> + manager.startup.account_generation=owner.startup.account_generation + && (if manager.resident_graphs=[] then + manager.selected_graph=Some owner.graph_id && manager.startup.graph_generation=owner.graph_generation + else List.mem owner.graph_id manager.resident_graphs + && match List.find_opt (fun (scope:Graph_service.asset_scope)->scope.graph_id=owner.graph_id) manager.resident_scopes with + | Some scope -> scope.graph_generation=owner.graph_generation + | None -> true) +;; +let prune_resident_models state = + let retained,drafts=Graph_drafts.fold(fun key saved (retained,drafts)-> + match timeline_scope saved with + | Some owner when not(runtime_owner_is_live state owner) -> + let capture_draft=Option.map(fun capture->match Journal_capture.phase capture with + | Saving -> Journal_capture.fail capture ~message:"Graph closed. Retry to confirm the original attempt." + | Editing | Failed _ -> capture) saved.direct_capture in + retained,Graph_drafts.add (Logseq_db_types.Graph_types.Uuid.to_string owner.graph_id) + {capture_draft;append_drafts=Journal_routes.retain_drafts ~interrupted:true saved.routes} drafts + | _ -> Graph_drafts.add key saved retained,drafts) + state.resident_states (Graph_drafts.empty,state.graph_drafts) in + {state with resident_states=retained;graph_drafts=drafts} +;; +let next_resident_owner state = + Option.bind state.manager (fun manager->Option.bind manager.selected_graph (fun current-> + Option.bind (Root_navigation.next_graph ~current manager.resident_graphs) (fun id-> + List.find_opt (fun (scope:Graph_service.asset_scope)->scope.graph_id=id) manager.resident_scopes + |> Option.map timeline_owner_of_scope))) +;; + +let graph_switch_enabled state = + Option.is_some(next_resident_owner state) && not state.graph_picker_open + && Journal_routes.route state.routes=Timeline + && Journal_routes.destination state.routes=Journal_routes.Journals + && state.modal=No_modal && not state.asset_settings_open && state.graph_ready +;; + let start ~on_view_region ~on_platform_request @@ -3059,13 +3154,55 @@ let start | None -> ()); drain_pending_actions () in + let state_ref = ref initial_state in + let processing_owner : Graph_service.timeline_scope option ref = ref None in + let visible_owner : Graph_service.timeline_scope option ref = ref None in + let preview_owner = ref None in + let pending_graph_pushes = Hashtbl.create 3 in + let graph_cells : ((unit -> unit -> unit) * (unit -> unit)) list ref = ref [] in + let graph_ref make = + let cell=ref(make ()) in + graph_cells := !graph_cells @ [(fun () -> let value= !cell in fun () -> cell:=value),(fun () -> cell:=make ())]; + cell in + let runtime_sessions = Hashtbl.create 3 in + let runtime_owners = Hashtbl.create 3 in + let retired_runtimes = Hashtbl.create 3 in + let active_runtime = ref None in + let owner_key (owner:Graph_service.timeline_scope) = + string_of_int owner.startup.account_generation ^ ":" ^ Logseq_db_types.Graph_types.Uuid.to_string owner.graph_id ^ ":" ^ string_of_int owner.graph_generation in + let model_key state = Option.map (fun owner -> owner_key owner) (timeline_scope state) in + let snapshot_cells () = List.map (fun (save,_) -> save ()) !graph_cells in + let save_runtime () = Option.iter (fun key -> + match Hashtbl.find_opt retired_runtimes key with + | Some(owner,_) -> Hashtbl.replace retired_runtimes key (owner,snapshot_cells ()) + | None -> Hashtbl.replace runtime_sessions key (snapshot_cells ())) !active_runtime in + let activate_runtime owner = + let key=owner_key owner in + Hashtbl.replace runtime_owners key owner; + if !active_runtime=None && !state_ref.graph_state.graph_id=Some owner.graph_id + && !state_ref.graph_state.generation=owner.graph_generation then ( + active_runtime:=Some key;save_runtime ()) + else if !active_runtime<>Some key then ( + save_runtime (); + (match Hashtbl.find_opt runtime_sessions key with + | Some restore -> List.iter (fun restore -> restore ()) restore + | None -> List.iter (fun (_,reset)->reset ()) !graph_cells); + active_runtime:=Some key) in + let current_owner () = match !processing_owner with Some _ as owner->owner | None->timeline_scope !state_ref in + let run_owned owner build = match owner with + | None -> Effect.run (build ()) + | Some owner -> send_action (Graph_update (owner,fun state->state,(fun ()->Effect.run (build ())))) in let set_state transition : unit Effect.t = - fun () -> send_action (Update (fun state -> transition state, Effect.ignore)) + let owner=current_owner () in + fun () -> match owner with + | None -> send_action (Update (fun state->transition state,Effect.ignore)) + | Some owner -> send_action (Graph_update (owner,fun state->transition state,Effect.ignore)) in let set_state_and_effect transition : unit Effect.t = - fun () -> send_action (Update transition) + let owner=current_owner () in + fun () -> match owner with None->send_action (Update transition) + | Some owner->send_action (Graph_update (owner,transition)) in - let state_ref = ref initial_state in let set_state_ref = ref None in set_state_ref := Some set_state; (* Platform requests are fire-and-forget: the host replies on the @@ -3086,13 +3223,15 @@ let start ~message:(Journal_bridge.platform_failure_message reason),Effect.ignore))) in let platform_request request ~f : unit Effect.t = + let owner=current_owner () in fun () -> Journal_platform_requests.emit platform_requests - ~on_result:(fun result -> Effect.run (f result)) request + ~on_result:(fun result -> run_owned owner (fun () -> f result)) request in - let copy_state = ref Journal_copy.initial in - let copy_owner = ref None in - let copy_sequence = ref 0L in - let copy_worker_requests = Hashtbl.create 2 in + let copy_state = graph_ref (fun () -> Journal_copy.initial) in + let copy_owner = graph_ref (fun () -> None) in + let copy_sequence = graph_ref (fun () -> 0L) in + let copy_worker_requests_cell = graph_ref (fun () -> + Hashtbl.create 2) in let projection_request_accepted = ref (fun _ -> ()) in let projection_request_finished = ref (fun _ -> ()) in let report_worker_refusal (request : Graph_service.request) (refused : Worker.send_result) = @@ -3109,14 +3248,14 @@ let start | Client_command (Set_foreground _) -> "setForeground" | Client_command Return_to_graph_picker -> "returnToGraphPicker" | Client_command _ -> "clientCommand" - | Graph_request _ -> "graphRequest" - | Import_asset _ -> "importAsset" + | Graph_request _ | Graph_request_at _ -> "graphRequest" + | Import_asset _ | Import_asset_at _ -> "importAsset" | Inspect_local_copy _ -> "inspectLocalCopy" | Delete_local_copy _ -> "deleteLocalCopy" - | Asset_command _ -> "assetCommand" + | Asset_command _ | Asset_command_at _ -> "assetCommand" | Acquire_imported_file _ | Acquire_asset_file _ -> "acquireAssetFile" | Release_asset_file _ -> "releaseAssetFile" - | Get_graph_state -> "getGraphState" + | Get_graph_state | Get_graph_state_at _ -> "getGraphState" in Effect.run (set_state (fun state -> report_ui_error state ~operation ~phase:"admission")) in @@ -3130,8 +3269,43 @@ let start ~projection_finished:(fun request_id -> !projection_request_finished request_id) ~refused:report_worker_refusal in + let worker_owners = Hashtbl.create 64 in + let release_worker_requests = Hashtbl.create 16 in let dispatch_worker ?observe_projection request = - Journal_worker_admission.dispatch worker_admission ?observe_projection request + let owner=current_owner () in + let targeted,owner=match owner,request with + | Some owner,Graph_service.Graph_request request -> Graph_service.Graph_request_at (owner,request),Some owner + | Some owner,Graph_service.Get_graph_state -> Graph_service.Get_graph_state_at owner,Some owner + | Some owner,Graph_service.Asset_command {command;_} -> Graph_service.Asset_command_at (owner,command),Some owner + | Some owner,Graph_service.Import_asset {source;_} -> Graph_service.Import_asset_at (owner,source),Some owner + | _,(Graph_request_at (scope,_) | Get_graph_state_at scope | Asset_command_at (scope,_) | Import_asset_at (scope,_)) -> request,Some scope + | _,(Acquire_asset_file {scope;_} | Acquire_imported_file {scope;_} | Release_asset_file {scope;_}) -> + request,Some(timeline_owner_of_scope scope) + | _,Client_command (Acknowledge_timeline_presented scope | Begin_online_recovery scope) -> request,Some scope + | _,request -> request,None in + let result=Journal_worker_admission.dispatch worker_admission ?observe_projection targeted in + (match result,owner with Worker.Accepted id,Some owner -> + Hashtbl.replace worker_owners id owner; + (match targeted with + | Release_asset_file _ | Asset_command_at (_,Release_asset_demand _) + | Asset_command {command=Release_asset_demand _;_} -> Hashtbl.replace release_worker_requests id () + | _ -> ()) + | _->()); + result + in + let pending_focus_refresh = ref None in + let try_focus_refresh owner = + match dispatch_worker (Graph_service.Get_graph_state_at owner) with + | Worker.Accepted _ | Stopping -> pending_focus_refresh:=None + | Full | Not_ready -> pending_focus_refresh:=Some owner + in + let drain_focus_refresh () = + Effect.of_thunk (fun () -> + match !pending_focus_refresh with + | Some owner when runtime_owner_is_live !state_ref owner + && model_key !state_ref=Some(owner_key owner) -> try_focus_refresh owner + | Some _ -> pending_focus_refresh:=None + | None -> ()) in let cancel_copy () = let state, _ = Journal_copy.step !copy_state Cancel in @@ -3142,7 +3316,7 @@ let start (fun request_id (_, _, protocol_id) -> !projection_request_finished protocol_id; Worker.cancel client ~request_id) - copy_worker_requests + (!copy_worker_requests_cell) in let copy_current owner = !copy_owner = Some owner @@ -3210,7 +3384,7 @@ let start match dispatch_worker ~observe_projection:true (Graph_service.Graph_request request) with | Accepted request_id -> Hashtbl.replace - copy_worker_requests + (!copy_worker_requests_cell) request_id (owner, ticket, request.request_id); None @@ -3237,23 +3411,26 @@ let start (Journal_routes.detail state.routes) }) ; copy_transition owner (Start root) ] in - let graph_runtime = + let graph_runtime_cell = graph_ref (fun () -> Journal_graph_runtime.create ~localtime:(Journal_calendar.Sampler.localtime calendar_sampler) - () - in - let media_worker_requests = Hashtbl.create 16 in - let media_changes = Hashtbl.create 16 in - let media_store = Journal_media_view.Store.create ~observe:on_view_region () in - let timeline_store = Journal_timeline.Store.create ~observe:on_view_region () in - let timeline_store_generation = ref initial_state.graph_state.generation in + ()) in + let media_worker_requests_cell = graph_ref (fun () -> + Hashtbl.create 16) in + let media_changes_cell = graph_ref (fun () -> + Hashtbl.create 16) in + let media_store_cell = graph_ref (fun () -> + Journal_media_view.Store.create ~observe:on_view_region ()) in + let timeline_store_cell = graph_ref (fun () -> + Journal_timeline.Store.create ~observe:on_view_region ()) in + let timeline_store_generation = graph_ref (fun () -> initial_state.graph_state.generation) in let prepare_presentation state = if !timeline_store_generation <> state.graph_state.generation then ( timeline_store_generation := state.graph_state.generation; - Journal_timeline.Store.reset timeline_store); + Journal_timeline.Store.reset (!timeline_store_cell)); let timeline_structure_revision = - Journal_timeline.Store.synchronize timeline_store state.timeline + Journal_timeline.Store.synchronize (!timeline_store_cell) state.timeline in if state.timeline_structure_revision = timeline_structure_revision then state @@ -3262,34 +3439,33 @@ let start ~reason:"publish-row-store" state state; { state with timeline_structure_revision }) in - ignore (Journal_timeline.Store.synchronize timeline_store initial_state.timeline : int); - let media_context = ref None in - let prev_media_owners = ref (media_presentation_scopes initial_state) in - let prev_detail_media_roots = ref (media_detail_roots initial_state) in - let prev_detail_media_routes = ref initial_state.routes in - let media_runtime = + ignore (Journal_timeline.Store.synchronize (!timeline_store_cell) initial_state.timeline : int); + let media_context = graph_ref (fun () -> None) in + let prev_media_owners = graph_ref (fun () -> (media_presentation_scopes initial_state)) in + let prev_detail_media_roots = graph_ref (fun () -> (media_detail_roots initial_state)) in + let prev_detail_media_routes = graph_ref (fun () -> initial_state.routes) in + let media_runtime_cell = graph_ref (fun () -> Journal_media_runtime.create ~send:(fun ticket request -> match dispatch_worker request with | Accepted id -> Option.iter - (fun ticket -> Hashtbl.replace media_worker_requests id ticket) + (fun ticket -> Hashtbl.replace (!media_worker_requests_cell) id ticket) ticket; true | Full | Not_ready | Stopping -> false) - ~changed:(fun root view -> Hashtbl.replace media_changes root view) - in + ~changed:(fun root view -> Hashtbl.replace (!media_changes_cell) root view)) in let sync_media state = let key = media_key state in if key <> !media_context then ( media_context := key; - Journal_media_view.Store.reset media_store; - Journal_media_runtime.reset media_runtime ~graph_generation:(Option.map fst key)) + Journal_media_view.Store.reset (!media_store_cell); + Journal_media_runtime.reset (!media_runtime_cell) ~graph_generation:(Option.map fst key)) in let flush_media set_state = - let changes = Hashtbl.to_seq media_changes |> List.of_seq in - Hashtbl.clear media_changes; + let changes = Hashtbl.to_seq (!media_changes_cell) |> List.of_seq in + Hashtbl.clear (!media_changes_cell); let context = !media_context in if changes = [] then Effect.ignore @@ -3302,19 +3478,23 @@ let start (fun (root, (view : Journal_media_runtime.view)) -> let empty = view.items = [] && view.error = None && not view.more in Journal_media_view.Store.update - media_store + (!media_store_cell) ~root (if empty then None else Some view)) changes; state)) in - let import_worker_requests = Hashtbl.create 2 in + let import_worker_requests_cell = graph_ref (fun () -> + Hashtbl.create 2) in (* operation token -> staged pick whose temp copy is removed on completion *) - let capture_staged_items = Hashtbl.create 2 in - let capture_import_queue = Queue.create () in + let capture_staged_items_cell = graph_ref (fun () -> + Hashtbl.create 2) in + let capture_import_queue_cell = graph_ref (fun () -> + Queue.create ()) in let drain_capture_imports = ref (fun () -> Effect.ignore) in - let asset_worker_requests = Hashtbl.create 2 in - let asset_runtime = + let asset_worker_requests_cell = graph_ref (fun () -> + Hashtbl.create 2) in + let asset_runtime_cell = graph_ref (fun () -> Journal_asset_runtime.create ~changed:(fun generation recent favorites -> Option.iter @@ -3333,44 +3513,43 @@ let start (match request with | Graph_service.Graph_request request -> sync_media !state_ref; - Journal_media_runtime.observe_request media_runtime request; + Journal_media_runtime.observe_request (!media_runtime_cell) request; Hashtbl.replace - asset_worker_requests + (!asset_worker_requests_cell) worker_id (media_key !state_ref, request.request_id) | _ -> ()); true - | Full | Not_ready | Stopping -> false) - in + | Full | Not_ready | Stopping -> false)) in let observe_projection_request request = sync_media !state_ref; - Journal_media_runtime.observe_request media_runtime request; - Journal_asset_runtime.observe_request asset_runtime request + Journal_media_runtime.observe_request (!media_runtime_cell) request; + Journal_asset_runtime.observe_request (!asset_runtime_cell) request in let forget_projection_request request_id = - Journal_media_runtime.forget_request media_runtime request_id; - Journal_asset_runtime.forget_request asset_runtime ~request_id + Journal_media_runtime.forget_request (!media_runtime_cell) request_id; + Journal_asset_runtime.forget_request (!asset_runtime_cell) ~request_id in projection_request_accepted := observe_projection_request; projection_request_finished := forget_projection_request; let observe_projection_response response = - Journal_media_runtime.observe_response media_runtime response; - Journal_asset_runtime.observe_response asset_runtime response + Journal_media_runtime.observe_response (!media_runtime_cell) response; + Journal_asset_runtime.observe_response (!asset_runtime_cell) response in let asset_settings = ref None in let refresh_assets ~graph_generation calendar = Option.iter (fun (calendar, settings) -> Journal_asset_runtime.refresh - asset_runtime + (!asset_runtime_cell) ~graph_generation ~today:(Journal_calendar.local_day calendar) ~settings) (Option.bind calendar (fun calendar -> Option.map (fun settings -> calendar, settings) !asset_settings)) in - let started_graph_generation = ref None in - let unlock_request = ref None in + let started_graph_generation = graph_ref (fun () -> None) in + let unlock_request = graph_ref (fun () -> None) in let unlock_owner state = Option.map (fun (snapshot : Graph_service.snapshot) -> snapshot.startup.account_generation, snapshot.startup.graph_generation) state.manager in let reject_unlock owner () = @@ -3404,7 +3583,8 @@ let start | Not_ready -> unlock_log "phase=queue result=not-ready"; reject_unlock owner () | Stopping -> unlock_log "phase=queue result=stopping"; reject_unlock owner ()) | (Graph_service.Restore_local_account _ | Local_account_unavailable _ | Reconcile_authenticated_user _ | Request_sign_in _ - | Cancel_online _ | Acknowledge_timeline_presented _ | Refresh_catalog _ | Begin_online_recovery _) -> + | Cancel_online _ | Acknowledge_timeline_presented _ | Refresh_catalog _ | Begin_online_recovery _ + | Configure_resident_graphs _ | Focus_resident_graph _) -> (match result with | Worker.Full | Not_ready -> let scope=startup_scope !state_ref in @@ -3417,7 +3597,10 @@ let start let drain_startup_commands () = let commands= !pending_startup_commands in pending_startup_commands:=[]; Effect.many (List.filter_map (fun (owner,command) -> - if owner=startup_scope !state_ref then Some (send_manager command) else None) commands) + let current=startup_scope !state_ref in + let live=owner=current || (owner.account_generation=current.account_generation && + match command with Graph_service.Configure_resident_graphs _ | Focus_resident_graph _ -> true | _->false) in + if live then Some (send_manager command) else None) commands) in let local_copy_requests = Hashtbl.create 2 in let visible_copy_queue = Queue.create () in @@ -3484,29 +3667,30 @@ let start (Graph_drafts.find_opt key state.graph_drafts); { state with graph_drafts = Graph_drafts.remove key state.graph_drafts })) in - let submit request = Journal_graph_runtime.submit graph_runtime request in + let submit request = Journal_graph_runtime.submit (!graph_runtime_cell) request in let fail_graph_transport state message = terminal_graph_state state (Transport_graph_error message) in - let graph_worker_requests = Hashtbl.create 16 in + let graph_worker_requests_cell = graph_ref (fun () -> + Hashtbl.create 16) in let deliver_output (output : Journal_graph_runtime.output) = List.iter (fun response -> match response.Journal_graph_runtime.payload with | Projection_changes windows -> - Journal_media_runtime.changes media_runtime windows; - Journal_asset_runtime.changes asset_runtime windows + Journal_media_runtime.changes (!media_runtime_cell) windows; + Journal_asset_runtime.changes (!asset_runtime_cell) windows | Projection_resync -> - Journal_media_runtime.resync media_runtime; - Journal_asset_runtime.resync asset_runtime + Journal_media_runtime.resync (!media_runtime_cell); + Journal_asset_runtime.resync (!asset_runtime_cell) | _ -> ()) output.responses; Journal_graph_transport.deliver - ~runtime:graph_runtime + ~runtime:(!graph_runtime_cell) ~send:(fun request -> match dispatch_worker ~observe_projection:true (Graph_service.Graph_request request) with | Accepted worker_id -> - Hashtbl.replace graph_worker_requests worker_id request; + Hashtbl.replace (!graph_worker_requests_cell) worker_id request; Journal_graph_transport.Accepted | Full -> Full | Not_ready -> Not_ready @@ -3514,9 +3698,9 @@ let start output in let retire_graph_scope () = - Journal_worker_admission.cancel_graph_leases worker_admission; - Hashtbl.clear graph_worker_requests; - Journal_graph_runtime.reset graph_runtime + Journal_worker_admission.cancel_graph_scope worker_admission (current_owner ()); + Hashtbl.clear (!graph_worker_requests_cell); + Journal_graph_runtime.reset (!graph_runtime_cell) in let rec apply_graph_responses set_state_and_effect responses = set_state_and_effect (fun state -> @@ -3643,6 +3827,9 @@ let start then cancel_copy (); let start_graph = match graph_state.phase with + | Graph_open when Option.is_some !processing_owner + && Option.map owner_key !processing_owner <> Option.map owner_key !visible_owner + && Option.map owner_key !processing_owner <> !preview_owner -> Effect.ignore | Graph_open -> let graph_key = Option.map Logseq_db_types.Graph_types.Uuid.to_string graph_state.graph_id @@ -3654,7 +3841,7 @@ let start started_graph_generation := Some (graph_key, graph_state.generation); retire_graph_scope (); let current = !state_ref in - let graph_info = Journal_graph_runtime.start graph_runtime in + let graph_info = Journal_graph_runtime.start (!graph_runtime_cell) in let feed_generation = current.next_request_generation in let feed_output = match current.calendar with @@ -3716,7 +3903,7 @@ let start | Graph_closed | Graph_opening | Graph_closing | Graph_failed -> retire_graph_scope (); started_graph_generation := None; - Journal_asset_runtime.shutdown asset_runtime; + Journal_asset_runtime.shutdown (!asset_runtime_cell); Effect.ignore in Effect.bind update ~f:(fun () -> @@ -3728,10 +3915,47 @@ let start ~graph_open:(graph_state.phase = Graph_open) ]) in + let release_runtime_staging () = + Queue.iter (fun (_,_,staged)->run_cleanup(Rrbvec.singleton(Discard_staged_file staged))) (!capture_import_queue_cell); + Queue.clear (!capture_import_queue_cell); + Hashtbl.iter (fun _ batch->run_cleanup(cleanup_batches [batch])) (!capture_staged_items_cell); + Hashtbl.clear (!capture_staged_items_cell) + in + let retire_absent_runtimes next = + let restore=snapshot_cells () and active= !active_runtime and previous= !processing_owner in + let retired=Hashtbl.to_seq runtime_owners |> List.of_seq + |> List.filter(fun (_,owner)->not(runtime_owner_is_live next owner)) in + List.iter (fun (key,owner)-> + Option.iter (List.iter(fun restore->restore ())) (Hashtbl.find_opt runtime_sessions key); + processing_owner:=Some owner;active_runtime:=Some key; + let pending=Hashtbl.to_seq worker_owners |> List.of_seq + |> List.filter_map(fun(id,scope)->if owner_key scope=key && not(Hashtbl.mem release_worker_requests id) then Some id else None) in + Journal_worker_admission.cancel_graph_scope worker_admission (Some owner); + List.iter(fun id->Worker.cancel client ~request_id:id) pending; + Journal_graph_runtime.reset (!graph_runtime_cell); + Journal_media_runtime.reset (!media_runtime_cell) ~graph_generation:None; + Journal_asset_runtime.shutdown (!asset_runtime_cell); + Hashtbl.replace retired_runtimes key (owner,snapshot_cells ()); + Hashtbl.remove runtime_sessions key;Hashtbl.remove runtime_owners key; + Hashtbl.remove pending_graph_pushes key) retired; + List.iter(fun restore->restore ()) restore;active_runtime:=active;processing_owner:=previous + in + let drain_retired_runtimes () = + let current=snapshot_cells () and active= !active_runtime and previous= !processing_owner in + Hashtbl.to_seq retired_runtimes |> List.of_seq |> List.iter(fun(key,(owner,restore))-> + List.iter(fun restore->restore ()) restore;active_runtime:=Some key;processing_owner:=Some owner; + Journal_media_runtime.pump (!media_runtime_cell);Journal_asset_runtime.pump (!asset_runtime_cell); + save_runtime (); + if not(Hashtbl.to_seq_values worker_owners |> Seq.exists(fun scope->owner_key scope=key)) + && Journal_media_runtime.idle (!media_runtime_cell) && Journal_asset_runtime.idle (!asset_runtime_cell) then ( + release_runtime_staging ();Hashtbl.remove retired_runtimes key)); + List.iter(fun restore->restore ()) current;active_runtime:=active;processing_owner:=previous + in let sign_out_in_flight = ref false in let termination_in_flight = ref false in let apply_manager_transition set_state set_state_and_effect manager_state = let manager = manager_state.Graph_service.snapshot in + retire_absent_runtimes { !state_ref with manager=Some manager }; let previous_owner = Option.map (fun manager -> @@ -3750,9 +3974,19 @@ let start (* Graph_state and manager pushes may arrive in either order. A manager update must not cancel work already started for its new graph scope. *) if (manager.startup.local_access=Graph_service.Local_pending && not manager.startup.restoring_local && not manager.startup.authenticated) || (owner_changed && not graph_already_current) - then (retire_graph_scope (); started_graph_generation := None); + then (if manager.resident_graphs=[] then (retire_graph_scope (); started_graph_generation := None)); if not manager.startup.authenticated then cancel_copy (); - let update = set_state (fun state -> manager_state_transition state manager_state) in + let update = set_state (fun state -> + let state=match manager.selected_graph with + | Some id when state.manager=None && state.graph_state.graph_id=Some id + && state.graph_state.generation=manager.startup.graph_generation -> + {state with manager=Some manager;draft_graph=Some id;graph_catalog=Rrbvec.of_list manager.catalog} + | Some id when List.mem id manager.resident_graphs + && (state.graph_state.graph_id<>Some id || state.graph_state.generation<>manager.startup.graph_generation) -> + let focused=step_root state (Graph_focused {generation=manager.startup.graph_generation;graph_id=id}) in + {focused with manager=Some manager;graph_catalog=Rrbvec.of_list manager.catalog} + | _ -> state in + manager_state_transition state manager_state) in let sign_out = if (not manager.Graph_service.startup.authenticated) && !sign_out_in_flight then ( @@ -3783,8 +4017,21 @@ let start Effect.bind update ~f:(fun () -> let snapshot = !state_ref in Effect.many - [ sign_out + [ (* A new graph's open push can precede the membership publication + and be fenced by the old scope list. Pull its state on focus. *) + (if owner_changed && not graph_already_current then + Effect.of_thunk (fun () -> + Option.iter (fun graph_id -> + let owner : Graph_service.timeline_scope = + {startup={account_generation=manager.startup.account_generation; + presentation_generation=manager.startup.presentation_generation}; + graph_id;graph_generation=manager.startup.graph_generation} in + try_focus_refresh owner) + manager.selected_graph) + else Effect.ignore) + ; sign_out ; termination_ready + ; observe_graph_state set_state set_state_and_effect snapshot.graph_state ; trigger_admission set_state_and_effect ~graph_generation:snapshot.graph_state.generation @@ -3795,10 +4042,11 @@ let start let retired = match event with | Worker.Response { request_id; _ } -> Journal_worker_admission.retire worker_admission request_id | _ -> false in - Journal_asset_runtime.pump asset_runtime; + Journal_asset_runtime.pump (!asset_runtime_cell); sync_media !state_ref; - Journal_media_runtime.pump media_runtime; + Journal_media_runtime.pump (!media_runtime_cell); match event with + | Worker.Push {payload=Graph_service.Graph_pushes _ | Graph_states _ | Session_states _;_} -> Effect.ignore | Worker.Response { request_id; outcome; _ } when Hashtbl.mem local_copy_requests request_id -> let request, deleting, confirm = Hashtbl.find local_copy_requests request_id in @@ -3846,9 +4094,9 @@ let start | Error message -> { state with local_copy_message = Some message })))) ~f:(fun () -> !drain_visible_copies ()) | Worker.Response { request_id; outcome; _ } - when Hashtbl.mem copy_worker_requests request_id -> - let owner, ticket, protocol_id = Hashtbl.find copy_worker_requests request_id in - Hashtbl.remove copy_worker_requests request_id; + when Hashtbl.mem (!copy_worker_requests_cell) request_id -> + let owner, ticket, protocol_id = Hashtbl.find (!copy_worker_requests_cell) request_id in + Hashtbl.remove (!copy_worker_requests_cell) request_id; let event = match outcome with | Completed @@ -3865,16 +4113,16 @@ let start in copy_transition owner event | Worker.Response { request_id; outcome = Completed response; _ } - when Hashtbl.mem media_worker_requests request_id -> - let ticket = Hashtbl.find media_worker_requests request_id in - Hashtbl.remove media_worker_requests request_id; - Journal_media_runtime.receive media_runtime ticket response; + when Hashtbl.mem (!media_worker_requests_cell) request_id -> + let ticket = Hashtbl.find (!media_worker_requests_cell) request_id in + Hashtbl.remove (!media_worker_requests_cell) request_id; + Journal_media_runtime.receive (!media_runtime_cell) ticket response; flush_media set_state | Worker.Response { request_id; outcome = Failed _ | Cancelled | Shutdown; _ } - when Hashtbl.mem media_worker_requests request_id -> - let ticket = Hashtbl.find media_worker_requests request_id in - Hashtbl.remove media_worker_requests request_id; - Journal_media_runtime.reject media_runtime ticket; + when Hashtbl.mem (!media_worker_requests_cell) request_id -> + let ticket = Hashtbl.find (!media_worker_requests_cell) request_id in + Hashtbl.remove (!media_worker_requests_cell) request_id; + Journal_media_runtime.reject (!media_runtime_cell) ticket; flush_media set_state | Worker.Push { payload = Graph_service.Graph_push push; _ } -> let snapshot = !state_ref in @@ -3890,7 +4138,7 @@ let start let generation = snapshot.next_request_generation in let output = Journal_graph_runtime.reconcile_push - graph_runtime + (!graph_runtime_cell) ~request_generation:generation push in @@ -3926,19 +4174,19 @@ let start ; outcome = Worker.Completed (Graph_service.Graph_response response) ; _ } - when Hashtbl.mem asset_worker_requests worker_id -> - let context, protocol_id = Hashtbl.find asset_worker_requests worker_id in - Hashtbl.remove asset_worker_requests worker_id; + when Hashtbl.mem (!asset_worker_requests_cell) worker_id -> + let context, protocol_id = Hashtbl.find (!asset_worker_requests_cell) worker_id in + Hashtbl.remove (!asset_worker_requests_cell) worker_id; let (Logseq_db_worker.Protocol.V2_response { request_id = actual; _ }) = response in if context = media_key !state_ref && Option.is_some context then if Logseq_db_types.Graph_types.Uuid.equal actual protocol_id - && Journal_asset_runtime.receive asset_runtime response - then Journal_media_runtime.observe_response media_runtime response + && Journal_asset_runtime.receive (!asset_runtime_cell) response + then Journal_media_runtime.observe_response (!media_runtime_cell) response else ( forget_projection_request protocol_id; - Journal_asset_runtime.reject asset_runtime ~request_id:protocol_id); + Journal_asset_runtime.reject (!asset_runtime_cell) ~request_id:protocol_id); Effect.ignore | Worker.Response { outcome = Completed (Graph_service.Graph_response _); _ } when retired -> @@ -3948,7 +4196,7 @@ let start ; outcome = Worker.Completed (Graph_service.Graph_response response) ; _ } -> - let expected = Hashtbl.find_opt graph_worker_requests request_id in + let expected = Hashtbl.find_opt (!graph_worker_requests_cell) request_id in let (Logseq_db_worker.Protocol.V2_response { request_id = actual; _ }) = response in let owned = match expected with @@ -3961,15 +4209,15 @@ let start false | None -> false in - Hashtbl.remove graph_worker_requests request_id; + Hashtbl.remove (!graph_worker_requests_cell) request_id; let output = if owned - then Journal_graph_runtime.receive graph_runtime response + then Journal_graph_runtime.receive (!graph_runtime_cell) response else ( match expected with | Some request -> Journal_graph_runtime.fail_request - graph_runtime + (!graph_runtime_cell) request ~message:"Worker response did not match the accepted request" | None -> Journal_graph_runtime.{ requests = []; responses = [] }) @@ -4001,8 +4249,8 @@ let start let notices = Graph_service.asset_notices payload in List.iter (fun (scope, notice) -> - Journal_asset_runtime.notice asset_runtime scope notice; - Journal_media_runtime.notice media_runtime scope notice) + Journal_asset_runtime.notice (!asset_runtime_cell) scope notice; + Journal_media_runtime.notice (!media_runtime_cell) scope notice) notices; Effect.many [ flush_media set_state @@ -4017,15 +4265,15 @@ let start }) ] | Worker.Response { request_id; outcome = Completed (Asset_imported result); _ } -> - let pending = Hashtbl.find_opt import_worker_requests request_id in - Hashtbl.remove import_worker_requests request_id; + let pending = Hashtbl.find_opt (!import_worker_requests_cell) request_id in + Hashtbl.remove (!import_worker_requests_cell) request_id; let capture_completion = match pending with | Some (_, operation, _) -> - (match Hashtbl.find_opt capture_staged_items operation with + (match Hashtbl.find_opt (!capture_staged_items_cell) operation with | None -> Effect.ignore | Some batch -> - Hashtbl.remove capture_staged_items operation; + Hashtbl.remove (!capture_staged_items_cell) operation; (match result with | Ok _ -> run_cleanup (cleanup_batches [ batch ]); @@ -4065,7 +4313,7 @@ let start Effect.bind (Effect.of_thunk (fun () -> sync_media !state_ref; - Result.iter (Journal_media_runtime.imported media_runtime ~current) result)) + Result.iter (Journal_media_runtime.imported (!media_runtime_cell) ~current) result)) ~f:(fun () -> Effect.many [ flush_media set_state @@ -4104,19 +4352,19 @@ let start (Effect.many [ capture_completion; completion ]) ~f:(fun () -> !drain_capture_imports ()) | Worker.Response { request_id; outcome = Failed _ | Cancelled | Shutdown; _ } - when Hashtbl.mem import_worker_requests request_id -> - let generation, operation, owner = Hashtbl.find import_worker_requests request_id in - Hashtbl.remove import_worker_requests request_id; + when Hashtbl.mem (!import_worker_requests_cell) request_id -> + let generation, operation, owner = Hashtbl.find (!import_worker_requests_cell) request_id in + Hashtbl.remove (!import_worker_requests_cell) request_id; let interruption = - if Hashtbl.mem capture_staged_items operation then + if Hashtbl.mem (!capture_staged_items_cell) operation then "Import was interrupted. Select Retry imports to try again." else "Import was interrupted. Select the file again." in let capture_completion = - match Hashtbl.find_opt capture_staged_items operation with + match Hashtbl.find_opt (!capture_staged_items_cell) operation with | None -> Effect.ignore | Some batch -> - Hashtbl.remove capture_staged_items operation; + Hashtbl.remove (!capture_staged_items_cell) operation; set_state (fun state -> import_failure_transition state batch) in let completion = @@ -4184,19 +4432,19 @@ let start { state with bootstrap_progress = Some progress } | None | Some _ -> state) | Worker.Response { request_id; outcome = Failed _ | Cancelled | Shutdown; _ } - when Hashtbl.mem asset_worker_requests request_id -> - let context, protocol_id = Hashtbl.find asset_worker_requests request_id in - Hashtbl.remove asset_worker_requests request_id; + when Hashtbl.mem (!asset_worker_requests_cell) request_id -> + let context, protocol_id = Hashtbl.find (!asset_worker_requests_cell) request_id in + Hashtbl.remove (!asset_worker_requests_cell) request_id; if context = media_key !state_ref && Option.is_some context then ( forget_projection_request protocol_id; - Journal_asset_runtime.reject asset_runtime ~request_id:protocol_id); + Journal_asset_runtime.reject (!asset_runtime_cell) ~request_id:protocol_id); Effect.ignore | Worker.Response { request_id; outcome = (Failed _ | Cancelled | Shutdown) as outcome; _ } - when Hashtbl.mem graph_worker_requests request_id -> - let request = Hashtbl.find graph_worker_requests request_id in - Hashtbl.remove graph_worker_requests request_id; + when Hashtbl.mem (!graph_worker_requests_cell) request_id -> + let request = Hashtbl.find (!graph_worker_requests_cell) request_id in + Hashtbl.remove (!graph_worker_requests_cell) request_id; forget_projection_request request.request_id; let message = match outcome with @@ -4205,7 +4453,7 @@ let start | Shutdown -> "Worker request interrupted by shutdown" | Completed _ -> assert false in - let output = Journal_graph_runtime.fail_request graph_runtime request ~message in + let output = Journal_graph_runtime.fail_request (!graph_runtime_cell) request ~message in Effect.bind (Effect.of_thunk (fun () -> deliver_output output)) ~f:(fun delivery -> @@ -4246,7 +4494,7 @@ let start terminal_graph_state state (Worker_graph_error (latest_worker_error state))) in let install_calendar set_state calendar = - Journal_graph_runtime.set_calendar graph_runtime calendar; + Journal_graph_runtime.set_calendar (!graph_runtime_cell) calendar; let snapshot = !state_ref in if snapshot.graph_state.phase = Graph_open then refresh_assets ~graph_generation:snapshot.graph_state.generation (Some calendar); @@ -4411,7 +4659,7 @@ let start else Some context | false, _ | true, None -> None in - let prev_feed_key = ref (feed_key initial_state) in + let prev_feed_key = graph_ref (fun () -> (feed_key initial_state)) in let feed_callback key = let snapshot = !state_ref in match key with @@ -4476,7 +4724,7 @@ let start let timeline_presentation_key state = Option.map (fun m -> startup_scope state,m.Graph_service.startup.graph_generation,m.startup.network_phase) state.manager in - let prev_timeline_presentation_key = ref (timeline_presentation_key initial_state) in + let prev_timeline_presentation_key = graph_ref (fun () -> (timeline_presentation_key initial_state)) in let timeline_presentation_callback = function | None -> Effect.ignore | Some (scope,_,phase) -> @@ -4487,7 +4735,7 @@ let start platform_request request ~f:(fun _ -> Effect.ignore) in let favorites_drain_key state = state.favorites_requests in - let prev_favorites_drain_key = ref (favorites_drain_key initial_state) in + let prev_favorites_drain_key = graph_ref (fun () -> (favorites_drain_key initial_state)) in let favorites_drain_callback requests = let deliver (request : Journal_graph_request.favorites_request) = if request.graph_generation <> !state_ref.graph_state.generation @@ -4516,7 +4764,7 @@ let start (fun request -> state.next_request_generation, request) (Journal_timeline_state.next_request state.timeline) in - let prev_timeline_drain_key = ref (timeline_drain_key initial_state) in + let prev_timeline_drain_key = graph_ref (fun () -> (timeline_drain_key initial_state)) in let timeline_drain_callback = function | None -> Effect.ignore | Some (generation, request) -> @@ -4564,7 +4812,7 @@ let start (fun block_id -> `Visible (Journal_detail.session_id detail, block_id)) (Journal_detail.next_automatic_load detail))) in - let prev_detail_drain_key = ref (detail_drain_key initial_state) in + let prev_detail_drain_key = graph_ref (fun () -> (detail_drain_key initial_state)) in let detail_drain_callback = function | None -> Effect.ignore | Some (`Initial key) -> @@ -4584,12 +4832,12 @@ let start , Effect.many (List.map send requests) ) | _ -> state, Effect.ignore) in - let prev_capture_imports_key = ref initial_state.capture_imports in + let prev_capture_imports_key = graph_ref (fun () -> initial_state.capture_imports) in let rec drain_imports () = - if Hashtbl.length capture_staged_items > 0 || Queue.is_empty capture_import_queue + if Hashtbl.length (!capture_staged_items_cell) > 0 || Queue.is_empty (!capture_import_queue_cell) then Effect.ignore else ( - let generation, target, staged = Queue.take capture_import_queue in + let generation, target, staged = Queue.take (!capture_import_queue_cell) in let operation = Journal_asset_import.staged_token staged in let failure = if @@ -4605,9 +4853,9 @@ let start }) with | Accepted request_id -> - Hashtbl.replace import_worker_requests request_id (generation, operation, None); + Hashtbl.replace (!import_worker_requests_cell) request_id (generation, operation, None); Hashtbl.replace - capture_staged_items + (!capture_staged_items_cell) operation { batch_generation = generation ; batch_target = Logseq_db_types.Graph_types.Uuid.to_string target @@ -4658,11 +4906,11 @@ let start | Ok target -> List.iter (fun staged -> - Queue.add (batch.batch_generation, target, staged) capture_import_queue) + Queue.add (batch.batch_generation, target, staged) (!capture_import_queue_cell)) batch.batch_items; drain_imports ()) in - let prev_upload_key = ref (upload_context initial_state) in + let prev_upload_key = graph_ref (fun () -> (upload_context initial_state)) in let upload_callback () = Effect.run (set_state (fun state -> @@ -4670,7 +4918,7 @@ let start uploads = Journal_uploads.sync state.uploads (upload_context state) })) in - let prev_media_key = ref (media_key initial_state) in + let prev_media_key = graph_ref (fun () -> (media_key initial_state)) in let media_callback () = Effect.run (Effect.bind @@ -4678,6 +4926,8 @@ let start ~f:(fun () -> flush_media set_state)) in let schedule_after span thunk = + let owner=current_owner () in + let thunk ()=run_owned owner (fun () -> Effect.of_thunk thunk) in ignore (Thread.create (fun () -> @@ -4697,7 +4947,7 @@ let start let detail_loading_key state = state.graph_state.generation, Journal_routes.loading_requests state.routes in - let prev_detail_loading_key = ref (detail_loading_key initial_state) in + let prev_detail_loading_key = graph_ref (fun () -> (detail_loading_key initial_state)) in let arm_detail_loading_timer graph_generation entry_id request_generation = let started = Mtime_clock.counter () in schedule_after 0.1 (fun () -> @@ -4709,24 +4959,18 @@ let start ~entry_id ~request_generation ~elapsed in if routes == state.routes then state else { state with routes })))) in - let sync_error_timer_generation = ref 0 in + let sync_error_timer_generation = graph_ref (fun () -> 0) in let arm_sync_error_timer sequence = incr sync_error_timer_generation; let generation = !sync_error_timer_generation in schedule_after sync_error_card_lifetime (fun () -> - if !sync_error_timer_generation = generation - then - send_action - (Update - (fun state -> - ( (match state.sync_error with - | Some { sequence = current; _ } when Int64.equal current sequence -> - { state with sync_error = None } - | None | Some _ -> state) - , Effect.ignore )))) + if !sync_error_timer_generation = generation then + Effect.run (set_state (fun state -> match state.sync_error with + | Some {sequence=current;_} when Int64.equal current sequence -> {state with sync_error=None} + | None | Some _ -> state))) in let prev_sync_error_key = - ref (Option.map (fun notice -> notice.sequence) initial_state.sync_error) + graph_ref (fun () -> Option.map (fun notice -> notice.sequence) initial_state.sync_error) in let handle_dispatch payload = let snapshot = !state_ref in @@ -4875,7 +5119,7 @@ let start ; capture_error_owner = Some Timeline_composer }) | Ok calendar -> - Journal_graph_runtime.set_calendar graph_runtime calendar; + Journal_graph_runtime.set_calendar (!graph_runtime_cell) calendar; let creation_time = Journal_time.of_calendar calendar |> Result.get_ok in let number = snapshot.next_local_sequence in let admission = @@ -5050,7 +5294,7 @@ let start in Journal_media_runtime.retain_visible_roots ~owner:(media_presentation_scope snapshot ~detail:false) - media_runtime + (!media_runtime_cell) (collect first []); let observe timeline = let total_count = Journal_timeline_state.total_count timeline in @@ -5103,7 +5347,7 @@ let start if index >= first && index < last then root else None) rows) in - Journal_media_runtime.retain_visible_roots ~owner media_runtime roots) + Journal_media_runtime.retain_visible_roots ~owner (!media_runtime_cell) roots) (List.assoc_opt owner (media_detail_roots snapshot)))) ~f:(fun () -> flush_media set_state) | Media (owner, event) when List.mem owner (media_presentation_scopes snapshot) -> @@ -5129,19 +5373,19 @@ let start | Root -> Journal_media_runtime.root_visible ~owner - media_runtime + (!media_runtime_cell) ~root:event.root event.visible | Asset -> Journal_media_runtime.asset_visible ~owner - media_runtime + (!media_runtime_cell) ~root:event.root ~asset:event.asset event.visible | Preview -> Journal_media_runtime.preview_visible - media_runtime + (!media_runtime_cell) ~owner ~slot:event.slot ~root:event.root @@ -5151,10 +5395,10 @@ let start | Retry -> Journal_media_runtime.retry ~owner - media_runtime + (!media_runtime_cell) ~root:event.root ~asset:event.asset - | Next -> Journal_media_runtime.next media_runtime ~root:event.root))) + | Next -> Journal_media_runtime.next (!media_runtime_cell) ~root:event.root))) ~f:(fun () -> flush_media set_state) | Import_asset value -> let import_payload = value in @@ -5189,7 +5433,7 @@ let start with | Accepted id -> Hashtbl.replace - import_worker_requests + (!import_worker_requests_cell) id ( graph_generation , operation @@ -5209,19 +5453,67 @@ let start | Some message -> update (fun state -> { state with import_completion = Some (operation, Some message) })))) + | Graph_swipe_prepare -> + (match next_resident_owner snapshot with + | None->Effect.ignore + | Some owner -> + preview_owner:=Some(owner_key owner); + Effect.bind (update(fun state->{state with graph_preview=Some(Logseq_db_types.Graph_types.Uuid.to_string owner.graph_id)})) + ~f:(fun ()->Effect.of_thunk(fun ()->run_owned (Some owner) (fun ()->Effect.of_thunk(fun ()-> + ignore(dispatch_worker Graph_service.Get_graph_state : Worker.send_result)))))) + | Graph_swipe_commit -> + (match next_resident_owner snapshot with + | Some owner when snapshot.graph_preview=Some(Logseq_db_types.Graph_types.Uuid.to_string owner.graph_id) -> + send_manager(Graph_service.Focus_resident_graph owner.graph_id) + | _->update(fun state->{state with graph_preview=None})) + | Graph_swipe_cancel -> + preview_owner:=None;update(fun state->{state with graph_preview=None}) + | Cancel_resident_replacement -> update (fun state->{state with resident_replacement=None}) + | Remove_resident value -> + let ids=Option.fold ~none:[] ~some:(fun (m:Graph_service.snapshot)-> + if m.resident_graphs=[] then Option.to_list m.selected_graph else m.resident_graphs) snapshot.manager in + let kept=List.filter (fun id->Logseq_db_types.Graph_types.Uuid.to_string id<>value) ids in + send_manager (Graph_service.Configure_resident_graphs kept) + | Move_resident (index,direction) -> + let ids=Option.fold ~none:[] ~some:(fun (m:Graph_service.snapshot)->m.resident_graphs) snapshot.manager in + let target=index+direction in + if target<0 || target>=List.length ids then Effect.ignore else + let moved=List.mapi (fun i id-> if i=index then List.nth ids target else if i=target then List.nth ids index else id) ids in + send_manager (Graph_service.Configure_resident_graphs moved) + | Replace_resident previous -> + (match snapshot.resident_replacement with + | None->Effect.ignore + | Some replacement -> + let ids=Option.fold ~none:[] ~some:(fun (m:Graph_service.snapshot)->m.resident_graphs) snapshot.manager in + match Logseq_db_types.Graph_types.Uuid.of_string replacement with + | Error _->Effect.ignore + | Ok graph_id -> + let ids=List.map (fun id->if Logseq_db_types.Graph_types.Uuid.to_string id=previous then graph_id else id) ids in + Effect.bind (update (fun state->{state with resident_replacement=None;graph_picker_open=false})) ~f:(fun ()-> + Effect.bind (send_manager (Graph_service.Configure_resident_graphs ids)) + ~f:(fun ()->send_manager (Graph_service.Focus_resident_graph graph_id)))) | Select_graph value -> - let graph_id = value in - (match Logseq_db_types.Graph_types.Uuid.of_string graph_id with - | Error _ -> Effect.ignore + (match Logseq_db_types.Graph_types.Uuid.of_string value with + | Error _->Effect.ignore | Ok graph_id -> - cancel_local_copy_checks (); - Effect.bind (update (fun state -> { state with graph_picker_open = false - ; local_copy_check = None; local_copy_message = None; modal = No_modal })) - ~f:(fun () -> - if Option.fold ~none:false ~some:(fun value -> - value.Graph_service.selected_graph=Some graph_id && value.startup.local_access=Graph_service.Local_available) snapshot.manager - then Effect.ignore - else send_manager (Graph_service.Select_graph graph_id))) + let ids=Option.fold ~none:[] ~some:(fun (m:Graph_service.snapshot)-> + if m.resident_graphs=[] then Option.to_list m.selected_graph else m.resident_graphs) snapshot.manager in + let retry_local=Option.fold ~none:false ~some:(fun (m:Graph_service.snapshot)-> + m.selected_graph=Some graph_id && + Option.fold ~none:false ~some:(fun (error:Journal_startup.startup_error)-> + error.recovery=Some Journal_startup.Retry_graph_open) + (Journal_startup.derive ~snapshot:m ~graph:snapshot.graph_state).error) snapshot.manager in + if retry_local then + Effect.bind (update (fun state->{state with graph_picker_open=false;resident_replacement=None; + local_copy_check=None;local_copy_message=None;modal=No_modal})) + ~f:(fun ()->send_manager (Graph_service.Select_graph graph_id)) + else if not (List.mem graph_id ids) && List.length ids>=3 then + update (fun state->{state with resident_replacement=Some value}) + else Effect.bind (update (fun state->{state with graph_picker_open=false;resident_replacement=None; + local_copy_check=None;local_copy_message=None;modal=No_modal})) ~f:(fun ()-> + let configured=if List.mem graph_id ids then ids else ids @ [graph_id] in + Effect.bind (send_manager (Graph_service.Configure_resident_graphs configured)) + ~f:(fun ()->send_manager (Graph_service.Focus_resident_graph graph_id)))) | Refresh_catalog -> Effect.bind (update (fun state -> { state with local_copy_message = None })) ~f:(fun () -> @@ -5356,7 +5648,7 @@ let start (fun staged -> Queue.add (batch.batch_generation, target, staged) - capture_import_queue) + (!capture_import_queue_cell)) batch.batch_items) batches; !drain_capture_imports ()) @@ -5414,6 +5706,7 @@ let start | _ -> state, Effect.ignore) | Sign_out -> pending_startup_commands:=[]; + pending_focus_refresh:=None; cancel_copy (); retire_graph_scope (); started_graph_generation := None; @@ -5501,7 +5794,7 @@ let start , Journal_routes.detail_request_generation state.routes )) (Some (Local_capture_failure (Journal_calendar.error_message error)))) | Ok calendar -> - Journal_graph_runtime.set_calendar graph_runtime calendar; + Journal_graph_runtime.set_calendar (!graph_runtime_cell) calendar; let creation_time = Journal_time.of_calendar calendar |> Result.get_ok in let number = snapshot.next_local_sequence in let admission = @@ -5775,7 +6068,7 @@ let start if !prev_media_owners <> owners then ( prev_media_owners := owners; - Journal_media_runtime.retain_owners media_runtime owners; + Journal_media_runtime.retain_owners (!media_runtime_cell) owners; Effect.run (flush_media set_state))); if !prev_detail_media_routes != model.routes then ( @@ -5788,7 +6081,7 @@ let start (fun (owner, rows) -> Journal_media_runtime.retain_visible_roots ~owner - media_runtime + (!media_runtime_cell) (List.filter_map Fun.id rows)) roots; Effect.run (flush_media set_state))); @@ -5806,16 +6099,55 @@ let start | Some sequence -> arm_sync_error_timer sequence)); model in + let graph_model root (owner:Graph_service.timeline_scope) = + let key=Logseq_db_types.Graph_types.Uuid.to_string owner.graph_id ^ ":" ^ string_of_int owner.graph_generation in + if model_key root=Some(owner_key owner) then root else + let saved=match Graph_drafts.find_opt key root.resident_states with + | Some saved -> saved + | None -> step_root initial_state (Graph_focused {generation=owner.graph_generation;graph_id=owner.graph_id}) in + let manager=match saved.manager,root.manager with + | Some saved,Some shared -> Some {saved with catalog=shared.catalog;resident_graphs=shared.resident_graphs; + startup={saved.startup with authenticated=shared.startup.authenticated;account_generation=shared.startup.account_generation}} + | None,Some shared -> Some {shared with selected_graph=Some owner.graph_id; + startup={shared.startup with graph_generation=owner.graph_generation;presentation_generation=owner.startup.presentation_generation}} + | saved,None -> saved in + {saved with resident_states=Graph_drafts.empty;manager;graph_catalog=root.graph_catalog; + local_copies=root.local_copies;local_copy_check=root.local_copy_check; + local_copy_deleting=root.local_copy_deleting;local_copy_message=root.local_copy_message; + calendar=root.calendar;environment=root.environment} in + let process_transition ~visible model transition = + Option.iter activate_runtime (timeline_scope model); + let model,eff=transition model in + Option.iter activate_runtime (timeline_scope model); + let previous= !processing_owner in + processing_owner:=timeline_scope model; + if visible then visible_owner:=timeline_scope model; + let model=track_capture_session model in + state_ref:=model; + Option.iter (fun calendar->Journal_graph_runtime.set_calendar (!graph_runtime_cell) calendar) model.calendar; + Effect.run eff; + state_ref:=model; + if visible then Option.iter (fun owner -> + let key=owner_key owner in + match Hashtbl.find_opt pending_graph_pushes key with + | None->() | Some build->Hashtbl.remove pending_graph_pushes key;Effect.run(build ())) (timeline_scope model); + let model=sync_startup_presentation model |> run_edge_callbacks |> prune_resident_models in + let model=if visible then prepare_presentation model else model in + state_ref:=model;save_runtime ();processing_owner:=previous; + if visible then (retire_absent_runtimes model;drain_retired_runtimes ());model in let update model = function - | Update transition -> - let model, eff = transition model in - let model = track_capture_session model in - state_ref := model; - Effect.run eff; - state_ref := model; - let model = sync_startup_presentation model |> run_edge_callbacks |> prepare_presentation in - state_ref := model; - model + | Graph_update (owner,_) when not(runtime_owner_is_live model owner) -> model + | Graph_update (owner,transition) -> + let visible=model_key model=Some(owner_key owner) in + let working=graph_model model owner in + let next=process_transition ~visible working transition in + let model=if visible then next + else let key=Logseq_db_types.Graph_types.Uuid.to_string owner.graph_id ^ ":" ^ string_of_int owner.graph_generation in + {model with resident_states=Graph_drafts.add key {next with resident_states=Graph_drafts.empty} model.resident_states; + graph_preview_revision=(if model.graph_preview=Some(Logseq_db_types.Graph_types.Uuid.to_string owner.graph_id) + then model.graph_preview_revision+1 else model.graph_preview_revision)} in + Option.iter activate_runtime (timeline_scope model);state_ref:=model;model + | Update transition -> process_transition ~visible:true model transition | Platform_response (tag, result) -> let current = Journal_platform_requests.resolve platform_requests ~response_tag:tag result in (match result with @@ -5875,6 +6207,9 @@ let start ~some:(fun manager -> manager.Graph_service.startup.awaiting_selection) state.manager in if picker left || picker right then picker left = picker right && left.graph_picker_open = right.graph_picker_open + && left.resident_replacement=right.resident_replacement + && Option.map (fun (m:Graph_service.snapshot)->m.resident_graphs) left.manager + = Option.map (fun (m:Graph_service.snapshot)->m.resident_graphs) right.manager && match left.manager, right.manager with | Some left, Some right -> left.catalog == right.catalog @@ -5953,7 +6288,12 @@ let start | _ -> left == right in let equal_shell_presentation left right = - left.graph_state.generation = right.graph_state.generation + left.graph_preview=right.graph_preview && left.graph_preview_revision=right.graph_preview_revision + && graph_switch_enabled left=graph_switch_enabled right + && next_resident_owner left=next_resident_owner right + && Option.map (fun (m:Graph_service.snapshot)->m.resident_scopes) left.manager + = Option.map (fun (m:Graph_service.snapshot)->m.resident_scopes) right.manager + && left.graph_state.generation = right.graph_state.generation && left.graph_state.graph_id = right.graph_state.graph_id && left.environment.platform = right.environment.platform in @@ -6122,16 +6462,11 @@ let start ] else V.empty ()) in - let root = - region "root" ~equal:equal_root_presentation (fun state -> - match Journal_startup.Presentation.screen state.startup_presentation with - | _ when state.graph_picker_open -> manager_page ~picker_signal:model_signal state dispatch - | Waiting | Interaction _ | Account_failure -> manager_page ~picker_signal:model_signal state dispatch - | Timeline -> + let render_graph_timeline ~interactive ~header_signal ~store ~media_store ~dispatch state = timeline_page ~status_name:(task_status_name state) - ~header_signal:(Some header_signal) - ~timeline_store:(Some timeline_store) + ~header_signal + ~timeline_store:(Some store) ~on_region:on_view_region ~render_source:(render_source state) ~render_title:(render_title state) @@ -6193,7 +6528,7 @@ let start (prefix_action dispatch (fun value -> Favorite_open_block value)) ~delete_enabled:state.write_enabled ~actions_enabled:(row_actions_enabled state) - ~interaction_enabled:true + ~interaction_enabled:interactive ~on_status:(prefix_action dispatch (fun value -> Timeline_status value)) ~on_delete:(prefix_action dispatch (fun value -> Timeline_delete value)) ~on_copy:(prefix_action dispatch (fun value -> Copy_block value)) @@ -6204,6 +6539,15 @@ let start ~account_menu_available:true ~on_account_action:dispatch ~cache_reset_available:(local_deletion_available state) + in + let root = + region "root" ~equal:equal_root_presentation (fun state -> + match Journal_startup.Presentation.screen state.startup_presentation with + | _ when state.graph_picker_open -> manager_page ~picker_signal:model_signal state dispatch + | Waiting | Interaction _ | Account_failure -> manager_page ~picker_signal:model_signal state dispatch + | Timeline -> + render_graph_timeline ~interactive:true ~header_signal:(Some header_signal) + ~store:(!timeline_store_cell) ~media_store:(!media_store_cell) ~dispatch state |> fun content -> V.of_lui (Lui_elements.stack ~grow:1. ~on_appear:(fun _ -> Option.iter (fun scope -> Effect.run (send_manager (Graph_service.Acknowledge_timeline_presented scope))) (timeline_scope state)) @@ -6335,7 +6679,7 @@ let start in Journal_view.mount (detail_page - ~media_store + ~media_store:(!media_store_cell) ~on_region:on_view_region ~state:focused ~on_scroll_completed:completion @@ -6512,6 +6856,29 @@ let start ; settings ] in + let preview=match state.graph_preview,next_resident_owner state with + | Some id,Some owner when id=Logseq_db_types.Graph_types.Uuid.to_string owner.graph_id -> + let restore=snapshot_cells () and previous= !active_runtime in + activate_runtime owner; + let target=graph_model state owner in + ignore(Journal_timeline.Store.synchronize (!timeline_store_cell) target.timeline : int); + let inert={send=(fun _->());native=Ui.Event.Handler.create(fun _->())} in + let content=render_graph_timeline ~interactive:false ~header_signal:None + ~store:(!timeline_store_cell) ~media_store:(!media_store_cell) ~dispatch:inert target + |> V.Body.Private.to_widget in + save_runtime ();List.iter (fun restore->restore ()) restore;active_runtime:=previous;content + | _->V.empty () in + let can_switch=graph_switch_enabled state in + let resident_page_ids = Option.fold ~none:[] ~some:(fun (manager:Graph_service.snapshot)-> + List.map (fun (scope:Graph_service.asset_scope)->owner_key + {startup={account_generation=scope.account.account_generation;presentation_generation=scope.account.presentation_generation}; + graph_id=scope.graph_id;graph_generation=scope.graph_generation}) manager.resident_scopes) state.manager in + let body=Journal_header.graph_pages ~enabled:can_switch + ~resident_page_ids ~target_page_id:(Option.map owner_key (next_resident_owner state)) + ~page_id:(Option.value (model_key state) ~default:"startup") + ~target_id:state.graph_preview ~on_prepare:(fun ()->dispatch.send Graph_swipe_prepare) + ~on_commit:(fun ()->dispatch.send Graph_swipe_commit) ~on_cancel:(fun ()->dispatch.send Graph_swipe_cancel) + ~current:body ~preview in V.Body.theme ~data:(application_theme ()) (V.Body.static body) in let view context model_signal _send = @@ -6576,12 +6943,14 @@ let start Option.iter arm_startup_loading_timer !prev_startup_loading_key; let release_staging () = ignore (finish_cleanup (with_cleanup (fun ~discard -> clear_account_drafts ~discard !state_ref))); - Queue.iter - (fun (_, _, staged) -> run_cleanup (Rrbvec.singleton (Discard_staged_file staged))) - capture_import_queue; - Queue.clear capture_import_queue; - Hashtbl.iter (fun _ batch -> run_cleanup (cleanup_batches [ batch ])) capture_staged_items; - Hashtbl.clear capture_staged_items + save_runtime (); + let all=Hashtbl.to_seq runtime_sessions |> List.of_seq + |> List.map(fun(key,restore)->key,restore) + |> fun active -> active @ (Hashtbl.to_seq retired_runtimes |> List.of_seq |> List.map(fun(key,(_,restore))->key,restore)) in + List.iter(fun(_,restore)->List.iter(fun restore->restore ()) restore; + Journal_media_runtime.reset (!media_runtime_cell) ~graph_generation:None; + Journal_asset_runtime.shutdown (!asset_runtime_cell);release_runtime_staging ()) all; + Hashtbl.clear runtime_sessions;Hashtbl.clear runtime_owners;Hashtbl.clear retired_runtimes in let fail_platform tag reason supplied_scope = Journal_platform_requests.fail platform_requests ~request_tag:tag ~reason ~supplied_scope @@ -6591,9 +6960,53 @@ let start in current_app := Some context; ignore (dispatch_worker Graph_service.Get_graph_state : Worker.send_result); + let owner_of_scope (scope:Graph_service.asset_scope) : Graph_service.timeline_scope = + {startup={account_generation=scope.account.account_generation;presentation_generation=scope.account.presentation_generation}; + graph_id=scope.graph_id;graph_generation=scope.graph_generation} in Worker.on_event client (fun event -> - Journal_pump.enqueue pump (fun () -> Effect.run - (Effect.bind (handle_worker_event event) ~f:drain_startup_commands))); + Journal_pump.enqueue pump (fun () -> + drain_retired_runtimes (); + let deliver event=Effect.bind + (Effect.bind (handle_worker_event event) ~f:drain_startup_commands) + ~f:drain_focus_refresh in + match event with + | Worker.Response {request_id;_} -> + let owner=Hashtbl.find_opt worker_owners request_id in + Hashtbl.remove worker_owners request_id; + Hashtbl.remove release_worker_requests request_id; + (match owner with + | Some owner when not(runtime_owner_is_live !state_ref owner) -> + let key=owner_key owner in + (match Hashtbl.find_opt retired_runtimes key with + | None -> ignore(Journal_worker_admission.retire worker_admission request_id) + | Some(_,restore) -> + let current=snapshot_cells () and active= !active_runtime and previous= !processing_owner in + List.iter(fun restore->restore ()) restore;active_runtime:=Some key;processing_owner:=Some owner; + Effect.run(deliver event);save_runtime (); + if not(Hashtbl.to_seq_values worker_owners |> Seq.exists(fun scope->owner_key scope=key)) + && Journal_media_runtime.idle (!media_runtime_cell) + && Journal_asset_runtime.idle (!asset_runtime_cell) then ( + release_runtime_staging ();Hashtbl.remove retired_runtimes key); + List.iter(fun restore->restore ()) current;active_runtime:=active;processing_owner:=previous) + | _ -> run_owned owner (fun () -> deliver event)) + | Worker.Push ({payload=Graph_service.Graph_states states;_} as envelope) -> + List.iter (fun (scope,state) -> let owner=owner_of_scope scope in + run_owned (Some owner) (fun () -> deliver (Worker.Push {envelope with payload=Graph_service.Graph_state_changed state}))) states + | Worker.Push ({payload=Graph_service.Session_states states;_} as envelope) -> + List.iter (fun (scope,state) -> let owner=owner_of_scope scope in + run_owned (Some owner) (fun () -> deliver (Worker.Push {envelope with payload=Graph_service.Client_state_changed state}))) states + | Worker.Push ({payload=(Graph_service.Asset_notice _ | Asset_notices _) as payload;_} as envelope) -> + List.iter (fun (scope,notice) -> + run_owned (Some(owner_of_scope scope)) (fun () -> + deliver (Worker.Push {envelope with payload=Graph_service.Asset_notice(scope,notice)}))) + (Graph_service.asset_notices payload) + | Worker.Push ({payload=Graph_service.Graph_pushes pushes;_} as envelope) -> + List.iter (fun (scope,push) -> let owner=owner_of_scope scope in + if model_key !state_ref=Some(owner_key owner) then + run_owned (Some owner) (fun () -> deliver (Worker.Push {envelope with payload=Graph_service.Graph_push push})) + else Hashtbl.replace pending_graph_pushes (owner_key owner) (fun () -> deliver (Worker.Push {envelope with payload=Graph_service.Graph_push push}))) pushes + | _ -> Effect.run (deliver event))); + (* The worker fires the wakeup on its own domain whenever output lands; enqueueing hops through the host wakeup onto the UI thread. An OCaml Condition waiter thread would deadlock against the worker domain (the diff --git a/app/application.mli b/app/application.mli index 6a1b4068..62dad215 100644 --- a/app/application.mli +++ b/app/application.mli @@ -97,6 +97,7 @@ module Root_navigation : sig | Capture_edited of string | Capture_admitted of Journal_capture.t | Completed of Journal_graph_runtime.response + | Graph_focused of { generation : int; graph_id : Logseq_db_types.Graph_types.Uuid.t } | Graph_replaced of { generation : int ; graph_id : Logseq_db_types.Graph_types.Uuid.t option @@ -104,6 +105,7 @@ module Root_navigation : sig | Account_cleared | Local_copy_deleted + val next_graph : current:Logseq_db_types.Graph_types.Uuid.t -> Logseq_db_types.Graph_types.Uuid.t list -> Logseq_db_types.Graph_types.Uuid.t option val diagnostic_rows : t -> (string * string) list val create : graph_generation:int -> t (** Compute state and ordered cleanup without executing file operations. diff --git a/app/journal_asset_runtime.ml b/app/journal_asset_runtime.ml index d0ac3baf..3380a88f 100644 --- a/app/journal_asset_runtime.ml +++ b/app/journal_asset_runtime.ml @@ -747,7 +747,8 @@ let shutdown t = Hashtbl.clear t.dependency_waiting; t.favorites_page <- None; t.favorite_members <- Uuids.empty; - Queue.clear t.queued; + retain_releases t; + pump t; t.configuration <- None; t.generation <- None; publish t @@ -847,3 +848,5 @@ let notice | Asset_availability { consumer; asset; availability } -> dispatch t (Availability { consumer; asset; availability })) ;; + +let idle t = Queue.is_empty t.queued diff --git a/app/journal_asset_runtime.mli b/app/journal_asset_runtime.mli index cebb07c5..260f435c 100644 --- a/app/journal_asset_runtime.mli +++ b/app/journal_asset_runtime.mli @@ -43,3 +43,5 @@ val notice val pump : t -> unit val reject : t -> request_id:Logseq_db_types.Graph_types.Uuid.t -> unit + +val idle : t -> bool diff --git a/app/journal_header.ml b/app/journal_header.ml index 658bff51..a7341e27 100644 --- a/app/journal_header.ml +++ b/app/journal_header.ml @@ -540,3 +540,16 @@ let reactive_view ~capture_expanded ~body ;; + +let graph_pages ~enabled ~page_id ~resident_page_ids ~target_page_id ~target_id ~on_prepare ~on_commit ~on_cancel ~current ~preview = + let payload=Yojson.Safe.to_string (`Assoc + ["mode",`String "graph-pages";"enabled",`Bool enabled;"pageID",`String page_id; + "targetID",(match target_id with None->`Null | Some id->`String id); + "residentPageIDs",`List(List.map(fun id->`String id) resident_page_ids); + "targetPageID",(match target_page_id with None->`Null | Some id->`String id)]) in + V.of_lui (Journal_lui_native.mount ~key:"journal-graph-pages" ~payload + ~on_event:(fun event->match event.Journal_lui_native.payload with + | "prepare"->on_prepare () | "commit"->on_commit () | "cancel"->on_cancel () | _->()) + ~children:[Lui_elements.stack ~grow:1. [Ui.mount current]; + Lui_elements.stack ~grow:1. [Ui.mount preview]] Journal_lui_native.chrome_identifier) +;; diff --git a/app/journal_header.mli b/app/journal_header.mli index 4811230a..488d35b4 100644 --- a/app/journal_header.mli +++ b/app/journal_header.mli @@ -90,3 +90,8 @@ val graph_picker -> controls:Journal_view.View.t -> Journal_view.View.Body.t -> Journal_view.View.Body.t + +val graph_pages : enabled:bool -> page_id:string -> resident_page_ids:string list + -> target_page_id:string option -> target_id:string option + -> on_prepare:(unit -> unit) -> on_commit:(unit -> unit) -> on_cancel:(unit -> unit) + -> current:Journal_view.View.t -> preview:Journal_view.View.t -> Journal_view.View.t diff --git a/app/journal_manager_view.ml b/app/journal_manager_view.ml index d3aa38f3..f8c6629c 100644 --- a/app/journal_manager_view.ml +++ b/app/journal_manager_view.ml @@ -4,7 +4,9 @@ module Graph_service = Logseq_db_worker_lui.Logseq_db_worker_lui_service module Presentation = Journal_page_layout type picker_live = - { status : string + { residents : string list + ; replacement : string option + ; status : string ; authenticated : bool ; checking : bool ; deleting : Graph_service.local_copy_request option @@ -49,6 +51,10 @@ type action = | Startup_retry | Refresh_catalog | Begin_online_recovery + | Remove_resident of string + | Move_resident of int * int + | Replace_resident of string + | Cancel_replacement | Select_graph of string | Switch_graph | Close_picker @@ -66,9 +72,9 @@ let handler on_action action = type unlock_chrome_props = { mode : string; title : string; pending : bool; error : string option [@default None] } [@@deriving yojson] -let graph_picker_list ?header ?on_visible_range rows = +let graph_picker_list ?header ?(residents=[]) ?on_visible_range rows = V.Native_list.vertical ~key:(Ui.Key.string "graph-list") ~style:Plain ?on_visible_range - [ V.Native_list.section ~key:(Ui.Key.string "graphs") ?header ~separator:Hidden rows ] + (residents @ [ V.Native_list.section ~key:(Ui.Key.string "graphs") ?header ~separator:Hidden rows ]) |> fun list -> V.column ~spacing:0. [list] |> V.with_test_id (Ui.Test_id.string "graph-picker-list") |> V.frame ~max_width:Fill ~max_height:Fill @@ -165,34 +171,53 @@ let view ?picker_signal props ~on_action = (if choose then choose_graph else V.empty ()) in let graph_picker snapshot = - let reactive ?grow ~equal build = - match picker_signal with - | None -> build snapshot.live - | Some signal -> V.of_lui - (Lui_elements.stack ?grow - [Lui_elements.dyn ~equal (fun current -> Ui.mount (build current)) signal]) - in - let icon_button ?(enabled = true) ?tooltip ~id ~command ~label icon = + let icon_button ?(enabled = true) ?disabled_signal ?tooltip ~id ~command ~label icon = V.of_lui (Lui_elements.button ~key:id ~accessibility_identifier:id - ~text:"" ~label ~icon ~size:`icon ~variant:`ghost ~disabled:(not enabled) ?tooltip + ~text:"" ~label ~icon ~size:`icon ~variant:`ghost ~disabled:(not enabled) ?disabled_signal ?tooltip ~on_press:(fun _ -> on_action command) []) in - let refresh = reactive - ~equal:(fun left right -> left.deleting = right.deleting) - (fun owner -> icon_button ~enabled:(owner.deleting = None) - ~id:"graph-picker-refresh" ~command:Refresh_catalog ~label:"Refresh graphs" - ~tooltip:"Refresh the authorized graph catalog" `refresh_cw) - in let projected context project = Option.map (fun source -> let signal = Signal.map project source in Signal.on_dispose context.Lui_ui.ui_scope (fun () -> Signal.dispose_signal signal); signal) picker_signal in + let refresh = V.of_lui (fun context parent -> + Ui.mount (icon_button ~enabled:(snapshot.live.deleting = None) + ?disabled_signal:(projected context (fun owner -> owner.deleting <> None)) + ~id:"graph-picker-refresh" ~command:Refresh_catalog ~label:"Refresh graphs" + ~tooltip:"Refresh the authorized graph catalog" `refresh_cw) context parent) + in let status_text current = current.status in let status = V.of_lui (fun context parent -> Lui_elements.text ~value:(status_text snapshot.live) ?value_signal:(projected context status_text) ~foreground:"error-foreground" ~accessibility_identifier:"graph-picker-local-copy-status" [] context parent) in + let resident_name id = Rrbvec.to_seq snapshot.graphs |> Seq.find_map (fun (graph:Graph_service.graph)-> + if Logseq_db_types.Graph_types.Uuid.to_string graph.graph_id=id then Some graph.name else None) + |> Option.value ~default:"Graph" in + let resident_rows=List.init 3 (fun index -> + let slot=string_of_int(index+1) in + let assigned=List.nth_opt snapshot.live.residents index in + let content=match assigned with + | None -> V.row ~spacing:12. [V.symbol ~name:"plus.circle" ();V.column ~alignment:Leading + [V.text ("Slot " ^ slot);V.text ~style:(Ui.Style.Text_style.create ~foreground:Secondary ()) "Choose a graph below"]] + | Some id -> V.row ~spacing:10. + [V.text slot;V.text(resident_name id) |> V.frame ~max_width:Fill + ; (match snapshot.live.replacement with + | Some _ -> button ~id:("graph-resident-replace:" ^ id) ~command:(Replace_resident id) "Replace" "arrow.triangle.2.circlepath" + | None -> V.row ~spacing:4. + [icon_button ~enabled:(index>0) ~id:("graph-resident-up:" ^ id) ~command:(Move_resident (index,-1)) + ~label:("Move " ^ resident_name id ^ " earlier") `arrow_up + ;icon_button ~id:("graph-resident-remove:" ^ id) ~command:(Remove_resident id) + ~label:("Close " ^ resident_name id) `x]) ] in + V.Native_list.row ~key:(Ui.Key.string ("resident-slot:" ^ slot)) ~separator:Hidden + (content |> V.frame ~max_width:Fill ~min_height:44.)) in + let resident_header=V.column ~alignment:Leading ~spacing:6. + ([V.text "Resident graphs · up to 3"] @ match snapshot.live.replacement with + | None -> [] | Some id -> [V.text ("Choose a slot to replace with " ^ resident_name id); + button ~id:"graph-resident-replace-cancel" ~command:Cancel_replacement "Cancel replacement" "xmark"]) in + let residents=[V.Native_list.section ~key:(Ui.Key.string "resident-graphs") + ~header:resident_header ~separator:Hidden resident_rows] in let content = match Rrbvec.is_empty snapshot.graphs with | true -> unavailable ~title:"No graphs available" ~symbol:"folder" @@ -215,7 +240,7 @@ let view ?picker_signal props ~on_action = let selection_enabled owner = owner.deleting = None in let deletion_enabled owner = owner.authenticated - && not current && not owner.checking + && not current && not (List.mem id owner.residents) && not owner.checking && owner.deleting = None && presence owner <> Some (Ok Graph_service.Not_downloaded) in (* Native List cells retain their interactive children. Property signals update status and enabled state without replacing callback owners. *) @@ -233,9 +258,7 @@ let view ?picker_signal props ~on_action = ~foreground:"secondary" [] context parent)] |> V.frame ~max_width:Fill ; (if current then V.symbol ~name:"checkmark.circle" () - else reactive ~equal:(fun left right -> presence left = presence right) - (fun owner -> if presence owner = Some (Ok Graph_service.Not_downloaded) - then V.symbol ~name:"icloud.and.arrow.down" () else V.column [])) ] + else V.empty ()) ] |> V.frame ~max_width:Fill ~min_height:44.) () |> V.with_test_id (Ui.Test_id.string ("graph-picker:" ^ id)) |> V.frame ~max_width:Fill in @@ -257,10 +280,11 @@ let view ?picker_signal props ~on_action = in V.Native_list.row ~key:(Ui.Key.string id) ~separator:Hidden row ) (Rrbvec.to_seq snapshot.graphs) |> List.of_seq in - graph_picker_list ~header:status + graph_picker_list ~header:status ~residents ~on_visible_range:(Ui.Event.Handler.create (function | Ui.Event.Payload.Visible_range range -> - on_action (Visible_range (range.first_index, range.last_exclusive)) + on_action (Visible_range (Int64.max 0L (Int64.sub range.first_index 3L), + Int64.max 0L (Int64.sub range.last_exclusive 3L))) | _ -> ())) rows in Journal_header.graph_picker diff --git a/app/journal_manager_view.mli b/app/journal_manager_view.mli index 992f1c22..fcbd6c3d 100644 --- a/app/journal_manager_view.mli +++ b/app/journal_manager_view.mli @@ -1,5 +1,7 @@ type picker_live = - { status : string + { residents : string list + ; replacement : string option + ; status : string ; authenticated : bool ; checking : bool ; deleting : Logseq_db_worker_lui.Logseq_db_worker_lui_service.local_copy_request option @@ -46,6 +48,10 @@ type action = | Startup_retry | Refresh_catalog | Begin_online_recovery + | Remove_resident of string + | Move_resident of int * int + | Replace_resident of string + | Cancel_replacement | Select_graph of string | Switch_graph | Close_picker diff --git a/app/journal_media_runtime.ml b/app/journal_media_runtime.ml index 7a3f9297..ce226d2f 100644 --- a/app/journal_media_runtime.ml +++ b/app/journal_media_runtime.ml @@ -1250,3 +1250,5 @@ let imported t ~current (receipt : Logseq_db_worker.import_receipt) = notify t g); pump t ;; + +let idle t = t.queued = [] diff --git a/app/journal_media_runtime.mli b/app/journal_media_runtime.mli index 899ecb28..d0a17ff7 100644 --- a/app/journal_media_runtime.mli +++ b/app/journal_media_runtime.mli @@ -61,3 +61,5 @@ val reject : t -> ticket -> unit val notice : t -> Service.asset_scope -> Service.asset_notice -> unit val pump : t -> unit val imported : t -> current:bool -> Logseq_db_worker.import_receipt -> unit + +val idle : t -> bool diff --git a/app/journal_native_collection.ml b/app/journal_native_collection.ml index c472f8cb..87d394b2 100644 --- a/app/journal_native_collection.ml +++ b/app/journal_native_collection.ml @@ -158,6 +158,7 @@ let view let list ?scroll_request_signal () = V.Native_list.vertical ~key ~style:Plain + ~retention_key:"timeline" ?scroll_request:(scroll_request scroll_target) ?scroll_request_signal ~on_scroll_completed diff --git a/app/journal_view.ml b/app/journal_view.ml index 84916422..ec1dcee5 100644 --- a/app/journal_view.ml +++ b/app/journal_view.ml @@ -1613,7 +1613,7 @@ module View = struct type props = { style : string; sections : section list ; track_visible_range : bool; track_scroll_completion : bool - ; sync_enabled : bool } + ; sync_enabled : bool; retention_key : string option [@default None] } [@@deriving yojson] type row_event = { row : string; key : string } [@@deriving yojson] type event = @@ -1631,7 +1631,7 @@ module View = struct ;; (* Content indexes retain the native child ownership order. *) - let build sections ~style ~track_visible ~track_scroll ~sync_enabled = + let build sections ~style ~track_visible ~track_scroll ~sync_enabled ~retention_key = let contents = ref Rrbvec.empty in let next_content_index = ref 0 in let push element = @@ -1658,7 +1658,7 @@ module View = struct in let wire : Json.props = { style = style_name style; sections = List.map section_wire sections - ; track_visible_range = track_visible; track_scroll_completion = track_scroll; sync_enabled } + ; track_visible_range = track_visible; track_scroll_completion = track_scroll; sync_enabled; retention_key } in Json.props_to_yojson wire |> Yojson.Safe.to_string, Rrbvec.to_list !contents ;; @@ -1696,6 +1696,7 @@ module View = struct ?on_scroll_completed ?on_visible_range ?on_sync + ?retention_key sections = let payload, contents = @@ -1705,6 +1706,7 @@ module View = struct ~track_visible:(Option.is_some on_visible_range) ~track_scroll:(Option.is_some on_scroll_completed) ~sync_enabled:(Option.is_some on_sync) + ~retention_key in (* Expansion state arrives as {"type":"expanded","key":..,"expanded":bool}; the owning row's handler receives Bool like the old disclosure callback. *) diff --git a/app/journal_view.mli b/app/journal_view.mli index bae8c8fe..655e3a95 100644 --- a/app/journal_view.mli +++ b/app/journal_view.mli @@ -703,6 +703,7 @@ module View : sig -> ?on_scroll_completed:Event.handler -> ?on_visible_range:Event.handler -> ?on_sync:Event.handler + -> ?retention_key:string -> section list -> t end diff --git a/app/journal_worker_admission.ml b/app/journal_worker_admission.ml index b1ac01d7..47a2a0ef 100644 --- a/app/journal_worker_admission.ml +++ b/app/journal_worker_admission.ml @@ -3,8 +3,14 @@ module Worker = Logseq_db_worker_lui.Journal_worker module Id = Logseq_db_worker_lui.Journal_worker_ids.Worker module Protocol = Logseq_db_worker.Protocol +type graph_owner = int * Logseq_db_types.Graph_types.Uuid.t * int + +let graph_owner = Option.map (fun (scope:Service.timeline_scope)-> + scope.startup.account_generation,scope.graph_id,scope.graph_generation) + type lease = - { protocol_request : Protocol.request + { scope : Service.timeline_scope option + ; protocol_request : Protocol.request ; mutable retired : bool } @@ -15,7 +21,7 @@ type t = ; projection_finished : Logseq_db_types.Graph_types.Uuid.t -> unit ; refused : Service.request -> Worker.send_result -> unit ; graph_requests : (Id.request_id, lease) Hashtbl.t - ; protocol_ids : (Logseq_db_types.Graph_types.Uuid.t, Id.request_id) Hashtbl.t + ; protocol_ids : (graph_owner option * Logseq_db_types.Graph_types.Uuid.t, Id.request_id) Hashtbl.t } let create ~send ~cancel ~projection_accepted ~projection_finished ~refused = @@ -27,16 +33,18 @@ let create ~send ~cancel ~projection_accepted ~projection_finished ~refused = let dispatch owner ?(observe_projection = false) (request : Service.request) = let protocol = match request with - | Graph_request request -> Some request + | Graph_request request | Graph_request_at (_,request) -> Some request | _ -> None in + let scope=match request with Service.Graph_request_at(scope,_) -> Some scope | _->None in + let protocol_key id=graph_owner scope,id in let duplicate = Option.fold ~none:false ~some:(fun request -> - Hashtbl.mem owner.protocol_ids request.Protocol.request_id) protocol in + Hashtbl.mem owner.protocol_ids (protocol_key request.Protocol.request_id)) protocol in if duplicate then Worker.Full else match owner.send request with | Accepted worker_id as accepted -> Option.iter (fun protocol -> - Hashtbl.add owner.graph_requests worker_id {protocol_request=protocol;retired=false}; - Hashtbl.add owner.protocol_ids protocol.Protocol.request_id worker_id; + Hashtbl.add owner.graph_requests worker_id {scope=(match request with Service.Graph_request_at (scope,_) -> Some scope | _ -> None);protocol_request=protocol;retired=false}; + Hashtbl.add owner.protocol_ids (protocol_key protocol.Protocol.request_id) worker_id; if observe_projection then owner.projection_accepted protocol) protocol; accepted | (Full | Not_ready | Stopping) as refused -> @@ -49,8 +57,9 @@ let retire owner worker_id = | None -> false | Some lease -> let protocol_id = lease.protocol_request.request_id in - if Hashtbl.find_opt owner.protocol_ids protocol_id = Some worker_id then - Hashtbl.remove owner.protocol_ids protocol_id; + let key=graph_owner lease.scope,protocol_id in + if Hashtbl.find_opt owner.protocol_ids key = Some worker_id then + Hashtbl.remove owner.protocol_ids key; Hashtbl.remove owner.graph_requests worker_id; lease.retired ;; @@ -63,3 +72,10 @@ let cancel_graph_leases owner = owner.cancel worker_id)) owner.graph_requests; Hashtbl.clear owner.protocol_ids ;; + +let cancel_graph_scope owner scope = + Hashtbl.iter (fun worker_id lease -> + if graph_owner lease.scope=graph_owner scope && not lease.retired then ( + lease.retired<-true;owner.projection_finished lease.protocol_request.request_id; + Hashtbl.remove owner.protocol_ids (graph_owner lease.scope,lease.protocol_request.request_id);owner.cancel worker_id)) owner.graph_requests +;; diff --git a/app/journal_worker_admission.mli b/app/journal_worker_admission.mli index 20017428..966bf32d 100644 --- a/app/journal_worker_admission.mli +++ b/app/journal_worker_admission.mli @@ -25,3 +25,5 @@ val retire : t -> Logseq_db_worker_lui.Journal_worker_ids.Worker.request_id -> b (** Cancel each live lease once, forgetting projection ownership first. Protocol IDs are reopened; worker-ID tombstones remain until their response arrives. *) val cancel_graph_leases : t -> unit + +val cancel_graph_scope : t -> Logseq_db_worker_lui.Logseq_db_worker_lui_service.timeline_scope option -> unit diff --git a/apple-tests/native-list/JournalListSnapshotTests.swift b/apple-tests/native-list/JournalListSnapshotTests.swift index 5246e1ff..56551e81 100644 --- a/apple-tests/native-list/JournalListSnapshotTests.swift +++ b/apple-tests/native-list/JournalListSnapshotTests.swift @@ -35,6 +35,20 @@ import Combine } static func settle() async { for _ in 0..<10 { await Task.yield() } } static func main() async throws { + let offsets = JournalGraphScrollOffsets() + offsets.retain(["account1:A:g1", "account1:B:g2"]) + offsets.write(page: "account1:A:g1", list: "timeline", y: 515) + offsets.write(page: "account1:B:g2", list: "timeline", y: 220) + check(offsets.read(page: "account1:A:g1", list: "timeline") == 515, "graph offsets remain independent") + offsets.retain(["account1:B:g2", "account1:A:g3"]) + offsets.write(page: "account1:A:g1", list: "timeline", y: 999) + check(offsets.read(page: "account1:A:g1", list: "timeline") == nil, "retired generation cannot restore or write") + check(offsets.read(page: "account1:B:g2", list: "timeline") == 220, "unrelated resident retains native offset") + check(offsets.read(page: "account1:A:g3", list: "timeline") == nil, "reopened graph starts with a fresh native offset") + offsets.retain([]) + offsets.write(page: "account1:B:g2", list: "timeline", y: 999) + check(offsets.read(page: "account1:B:g2", list: "timeline") == nil, "logout fences late geometry") + check(JournalGraphScrollOffsets().read(page: "account1:B:g2", list: "timeline") == nil, "runtime instances never share offsets") var captured: LUIAppleExtensionViewContext? let registry = LUIAppleExtensionRegistry() try registry.register(LUIAppleExtension(identifier: "journal-list", fingerprint: "snapshot-test", diff --git a/apple-tests/native-list/JournalResidentGraphAcceptance.swift b/apple-tests/native-list/JournalResidentGraphAcceptance.swift new file mode 100644 index 00000000..3a4bf829 --- /dev/null +++ b/apple-tests/native-list/JournalResidentGraphAcceptance.swift @@ -0,0 +1,94 @@ +import XCTest +/// Run only against the isolated four-catalog / three-resident synthetic host. +/// Native pixel offset is owned by List; the public reducer cannot reproduce it. +final class JournalResidentGraphAcceptance: XCTestCase { + @MainActor private func host() -> XCUIApplication { + let app=XCUIApplication(bundleIdentifier:"org.logseq.journal.resident-sync-probe") + app.launchEnvironment=["LOGSEQ_JOURNAL_E2EE_TEST_PRIVATE_KEY_STORAGE":"memory","LOGSEQ_JOURNAL_E2EE_TEST_WRAPPED_KEY_STORAGE":"memory","JOURNAL_SYNTHETIC_TLS_SHA256":ProcessInfo.processInfo.environment["JOURNAL_SYNTHETIC_TLS_SHA256"] ?? ""] + app.launchArguments=["--fixture",ProcessInfo.processInfo.environment["JOURNAL_SYNTHETIC_FIXTURE"] ?? "authoritative-v2/fixture.json","--support-root",ProcessInfo.processInfo.environment["JOURNAL_SYNTHETIC_SUPPORT_ROOT"] ?? "authoritative-v2/resident-sync-support","--application-only"] + app.launch(); _ = app.collectionViews.firstMatch.waitForExistence(timeout:25) + Thread.sleep(forTimeInterval:5) + picker(app) + let initialGraph=app.buttons.matching(NSPredicate(format:"label BEGINSWITH %@","Native encrypted graph 1")).firstMatch + XCTAssertTrue(initialGraph.waitForExistence(timeout:15));initialGraph.tap() + _ = title(1,app).waitForExistence(timeout:20) + return app + } + @MainActor private func title(_ graph:Int,_ app:XCUIApplication)->XCUIElement { + app.staticTexts.matching(NSPredicate(format:"label BEGINSWITH %@","Graph \(graph) —")).firstMatch + } + @MainActor private func record(_ name:String,_ app:XCUIApplication) { + let ax=XCTAttachment(string:app.debugDescription);ax.name=name+" AX";ax.lifetime = .keepAlways;add(ax) + let shot=XCTAttachment(screenshot:XCUIScreen.main.screenshot());shot.name=name;shot.lifetime = .keepAlways;add(shot) + } + @MainActor private func right(_ app:XCUIApplication) { + let start=app.coordinate(withNormalizedOffset:CGVector(dx:0.15,dy:0.53)) + let end=app.coordinate(withNormalizedOffset:CGVector(dx:0.85,dy:0.53)) + start.press(forDuration:0.05,thenDragTo:end,withVelocity:.slow,thenHoldForDuration:0.2) + Thread.sleep(forTimeInterval:0.6) + } + @MainActor func testSmokeRightCycle() { + continueAfterFailure=false + let app=host();XCTAssertTrue(title(1,app).waitForExistence(timeout:25));record("Synthetic graph1 before real native swipe",app) + for graph in [2,3,1] { + right(app);XCTAssertTrue(title(graph,app).waitForExistence(timeout:10));XCTAssertTrue(title(graph,app).isHittable);record("Native right swipe graph\(graph)",app) + } + } + @MainActor private func drag(_ app:XCUIApplication,_ from:CGFloat,_ to:CGFloat) { + app.coordinate(withNormalizedOffset:CGVector(dx:from,dy:0.53)).press(forDuration:0.05,thenDragTo:app.coordinate(withNormalizedOffset:CGVector(dx:to,dy:0.53)),withVelocity:.slow,thenHoldForDuration:0.2) + } + @MainActor private func tapID(_ id:String,_ app:XCUIApplication) { + if id.hasPrefix("graph-resident-remove:"), let n=id.last {app.otherElements.matching(NSPredicate(format:"label == %@","Close Native encrypted graph \(n)")).firstMatch.tap();return} + if id.hasPrefix("graph-resident-up:"), let n=id.last {app.otherElements.matching(NSPredicate(format:"label == %@","Move Native encrypted graph \(n) earlier")).firstMatch.tap();return} + if id.hasPrefix("graph-picker:"), let n=id.last {app.buttons.matching(NSPredicate(format:"label BEGINSWITH %@","Native encrypted graph \(n)")).firstMatch.tap();return} + if id.hasPrefix("graph-resident-replace:") {app.buttons.matching(NSPredicate(format:"label == %@","Replace")).element(boundBy:2).tap();return} + let target=app.descendants(matching:.any).matching(identifier:id).firstMatch + XCTAssertTrue(target.waitForExistence(timeout:10));target.tap() + } + @MainActor func test04CancellationVerticalAndScroll() { + continueAfterFailure=false + let app=host();XCTAssertTrue(title(1,app).waitForExistence(timeout:20)) + let initialY=title(1,app).frame.minY + title(1,app).tap();XCTAssertTrue(app.staticTexts["detail-block:80000000-0000-4000-a000-000000000001"].waitForExistence(timeout:10));record("A native detail",app) + right(app);XCTAssertTrue(title(1,app).isHittable);record("Detail right drag keeps graph1 and allows native back",app) + if app.staticTexts["detail-block:80000000-0000-4000-a000-000000000001"].exists {app.navigationBars.buttons["Back"].firstMatch.tap()};XCTAssertTrue(title(1,app).waitForExistence(timeout:10));XCTAssertEqual(title(1,app).frame.minY,initialY,accuracy:4) + drag(app,0.85,0.15);XCTAssertTrue(title(1,app).isHittable);record("Left drag stays on graph1",app) + drag(app,0.15,0.27);XCTAssertTrue(title(1,app).isHittable);XCTAssertTrue(title(1,app).isHittable);record("Short right drag cancels",app) + XCTAssertEqual(app.pageIndicators.count,0) + app.collectionViews.firstMatch.swipeUp(velocity:.slow) + let anchor=app.staticTexts["Native row 0008 — readable content"].firstMatch + XCTAssertTrue(anchor.isHittable);let y=anchor.frame.minY + XCTAssertFalse(title(1,app).isHittable);record("A vertical scroll before graph change",app) + right(app);XCTAssertTrue(title(2,app).isHittable);record("B independent initial scroll",app) + right(app);XCTAssertTrue(title(3,app).isHittable) + right(app);XCTAssertTrue(anchor.isHittable);record("A scroll after full graph cycle",app) + XCTAssertEqual(anchor.frame.minY,y,accuracy:4,"Each graph must keep its own native scroll position") + } + + @MainActor private func picker(_ app:XCUIApplication) { + let header=app.staticTexts["Resident graphs · up to 3"] + for _ in 0..<2 { + if header.exists {return} + XCTAssertFalse(app.staticTexts["Unable to open journal"].exists) + app.buttons["Options"].firstMatch.tap();app.buttons["Switch graph"].firstMatch.tap() + if header.waitForExistence(timeout:8) {return} + record("Menu selection before picker admission",app) + } + XCTAssertTrue(header.exists) + } + @MainActor private func gid(_ n:Int)->String {"60000000-0000-4000-8000-00000000000\(n)"} + @MainActor func test06ResidentManagement() { + continueAfterFailure=false;let app=host();XCTAssertTrue(title(1,app).waitForExistence(timeout:20));picker(app);record("Three resident slots in Switch graph",app) + for n in [2,3] {tapID("graph-resident-remove:"+gid(n),app)} + record("Only graph1 resident",app);tapID("graph-picker-close",app);right(app);XCTAssertTrue(title(1,app).isHittable);record("One graph right drag stays",app) + picker(app);tapID("graph-picker:"+gid(2),app);XCTAssertTrue(title(2,app).waitForExistence(timeout:20));right(app);XCTAssertTrue(title(1,app).isHittable);right(app);XCTAssertTrue(title(2,app).isHittable);record("Two graph right wrap",app) + picker(app);tapID("graph-picker:"+gid(3),app);XCTAssertTrue(title(3,app).waitForExistence(timeout:20));picker(app) + tapID("graph-resident-up:"+gid(3),app);record("Configured order A C B",app) + tapID("graph-picker:"+gid(4),app);XCTAssertTrue(app.staticTexts["Choose a slot to replace with Native encrypted graph 4"].waitForExistence(timeout:10));record("Full set requires explicit replacement",app) + tapID("graph-resident-replace:"+gid(2),app);XCTAssertTrue(title(4,app).waitForExistence(timeout:20));record("Explicit replacement opens D",app) + picker(app);tapID("graph-resident-remove:"+gid(4),app);record("D closed without deleting local copy",app);picker(app);tapID("graph-picker:"+gid(4),app);XCTAssertTrue(title(4,app).waitForExistence(timeout:20));record("Reopen D local copy",app) + app.terminate();app.launch();XCTAssertTrue(title(4,app).waitForExistence(timeout:20));record("Resident selection after process restart",app) + for n in [1,3,4] {right(app);XCTAssertTrue(title(n,app).isHittable);record("Persisted cycle graph\(n)",app)} + } + +} diff --git a/apple-tests/native-list/README.md b/apple-tests/native-list/README.md index 644df0e8..934d955b 100644 --- a/apple-tests/native-list/README.md +++ b/apple-tests/native-list/README.md @@ -97,3 +97,16 @@ The final main-aligned acceptance uses main `efb97631d9f1814803abe814bd3305fc6d1 The Git-external runner also toggles an outer sign-in notice with exactly the production App.swift safeAreaInset shape. This verifies insertion/removal and native Back geometry; it does not claim to execute a real account authentication failure. That fixture-only method is not part of this repository test file. The current-main unmodified control reproduces 17 sharp top frames in 348 matching normal-content frames across two native Back operations; the coherent soft candidate has zero in 301. Final image/top/menu/rotation/preview and software-keyboard acceptance also pass. With the outer notice and no diagnostic overlay, the collection container reports isHittable=false before and after navigation while actual menu taps and a second Detail navigation from root text succeed. This container flag alone is not an acceptance criterion for those fixture controls. + +## Resident graph scroll ownership + +`JournalResidentGraphAcceptance.swift` exercises the isolated four-catalog, +three-resident full-runtime host with native right swipes, cancellation, vertical +scrolling and the Switch graph controls. Supply the current loopback fixture's +certificate pin through `JOURNAL_SYNTHETIC_TLS_SHA256`; use synthetic data only. +The List's pixel offset cannot be reproduced by the pure OCaml reducer. The +original native regression loses row8's offset after a complete graph cycle. +SwiftUI `ScrollPosition` did not restore this List in the same failing case, so +restoration reuses the existing resolved `UIScrollView` and native offset API. +`JournalListSnapshotTests.swift` additionally verifies runtime separation, +resident-generation retirement and rejection of late offset writes after logout. diff --git a/docs/agent-guide/proposed/architecture/2026-10-28-resident-graphs.md b/docs/agent-guide/proposed/architecture/2026-10-28-resident-graphs.md new file mode 100644 index 00000000..7da22b20 --- /dev/null +++ b/docs/agent-guide/proposed/architecture/2026-10-28-resident-graphs.md @@ -0,0 +1,228 @@ +# Resident Graphs + +## Problem + +The requested feature keeps up to three graphs open in one account. Hidden graphs +continue synchronization while only the visible graph computes presentation work. +The current application equates selection with graph lifetime, so switching resets +runtime state and can interrupt an in-flight Capture save. + +Inspection baseline: `ae63fadba3be93aef2a69ebd037db3887eb02935` (remote main +verified on 2026-10-28). The implementation now separates account and graph owners. + +Observed ownership boundaries: + +- `logseq_sync/lib/pure_reducer/core.ml:1481`: selection increments graph and + presentation generations and calls `reset_graph_operations`. The current + graph's key, bootstrap, pull and submission state are replaced. +- `logseq_sync/lib/pure_reducer/core.ml:3776`: changing the target retires its + websocket; the hidden graph therefore cannot keep synchronizing. +- `logseq_db_worker/lib/pure_reducer/core.ml:183` and `:434`: one database owns + every unscoped `Graph_request`. +- `logseq_db_worker/lib/effect_runner/effect_runner.ml:493`: asset validity + depends on `snapshot.selected_graph`, invalidating hidden graphs' work. +- `logseq_db_worker/lui/logseq_db_worker_lui_service.mli`: `Graph_request` and + `Get_graph_state` carry no target; non-asset topic coalescing keeps only the + newest push, which would lose another graph's pending invalidation. +- `app/application.ml:693`, `:3240` and `:3516`: selection interrupts saving, + resets the sole graph runtime and retires graph leases. + +## Proposal + +Separate one account owner from up to three graph sessions and one visible graph. +Each session owns its DB, sync connection, request/action queues, subscription, +assets, runtime caches, navigation, Capture/append drafts and retained UI position. +Auth, ID-token acquisition/cache, catalog and platform lifecycle remain shared. + +Use the complete existing graph scope (account, graph UUID and lifetime +generation) as the target of every graph operation and completion. A visibility +change must not increment that lifetime, cancel leases, detach DBs, fail saves or +rebind queued effects to the current UI. Closing/replacing a session retires only +that exact scope. Reopening the same UUID gets a fresh lifetime; old completions +may release their own resources but cannot update the reopened session. + +Protected contract changes explicitly authorized by the user: + +| Contract | Required change | Reason | +| --- | --- | --- | +| `logseq_sync/spec/pure_reducer/core.mli` | Separate resident configuration/open/close/replace from visible focus events | Focus must leave synchronization owners alive | +| Same | Query admitted scope, asset context and synchronization state by graph; expose bounded resident session state | A single selected graph cannot be the admission authority | +| Same | Add graph scope to `Local_outbox_changed` | A hidden graph's mutation must wake its own submission queue | +| Same | Extend catalog-cache encoding/decoding for ordered residents and last visible graph, with old single-selection format migration | Restore the same order across restart without creating a second catalog owner | +| `logseq_db_worker/spec/pure_reducer/core.mli` | Scope `Graph_request`, projection events/pushes, asset/upload admission and `import_context` | Request destination cannot be inferred at execution time | +| Same | Represent per-graph DB/assets/recovery state and scoped lifecycle publication/results | Closing one graph must not close another; late closes must identify their owner | + +Propagate these contracts through existing `logseq_db_worker/lib` and `lui` +interfaces, request admission, transport, asset/media runtimes and application +state. Coalesce graph pushes per scope, preserving all pending graphs. Bound +resident membership to three unique authorized catalog entries. Keep lifetime +generations unique and separate from UI presentation generations. + +No current evidence requires changes to either effect-runner `.mli` under `spec/` +or to Dune files. Use existing compiled modules/test entry points. Any later +protected-contract need must identify the exact missing boundary first. + +Switch graph owns the three resident slots and their order. At capacity, adding +another graph requires an explicit replacement target; it never evicts silently. +Removing a resident closes only its runtime, preserving its local mirror. Logout +retires all sessions for the account. No concurrent multi-account use is added. + +Timeline retains its current appearance: no graph title strip, dots, drawer or +hint. A right drag moves the whole page, including its existing floating controls, +with the next configured graph entering from the left. Only a committed right +gesture changes focus; a cancelled gesture returns to its origin. One resident +does not install a switching gesture. Two/three wrap in configured order. Left +drags never switch. Native vertical scrolling wins; detail back gestures, +composer, keyboard, image preview and QuickLook retain their gestures. Use native +SwiftUI/UIKit root-page APIs, without per-row geometry or scroll scans. + +Hidden synchronization applies data and marks presentation interests dirty; it +does not hydrate/re-render each hidden Timeline on each push. Focus refreshes +from the graph's current DB before publishing its new visible presentation. +Pending Capture commands keep their original graph/session and mutation identity. + +### Reviewable implementation batches + +Deliver one complete non-Draft PR, with internal commits for the following work: + +1. Add the required failing behavioral tests up front through existing public + reducer boundaries and integration surfaces. Cover all rows in Acceptance + criteria. Observe expected failures before implementation, separating a + missing contract compile error from a demonstrated behavior failure. +2. Implement account/resident ownership, stable per-graph scopes and configuration + persistence/migration. Make lifecycle/admission tests pass first. +3. Implement per-scope worker/DB/effect routing, coalescing and resource retirement. + Prove hidden synchronization and stale-completion fencing independently from UI. +4. Split application graph state/stores/drafts from account/platform state, and + make focus a presentation operation. Preserve Capture saves during focus changes. +5. Add resident management to Switch graph, then native whole-page right-only + interaction with cancellation and gesture arbitration. Refactor with tests green. +6. Run final source build/tests, independent read-only review of exact final + SHA/diff, isolated Simulator GUI acceptance and actual recording; fix findings, + revalidate affected evidence, then create and attach the formal PR. Do not merge + or publish a release. + +### Evidence preparation and target policy + +Both user-provided PNG references were materialized through Library and visually +inspected. The storyboard's old left-swipe/non-loop annotations are superseded by +the latest request; only its whole-page translation is applicable. + +Two booted iOS 26.1 Simulators were identified read-only; neither was installed, +erased, signed out or seeded by this task. Their real contents are unverified. +Use a new task-owned Simulator and/or a distinct probe bundle/container with +synthetic graphs. Existing warm-start fixtures support four synthetic encrypted +graphs with memory-only secrets, but block network auth: by themselves they do +not establish hidden server synchronization. Use an isolated synthetic transport +fixture for synchronization acceptance and state that evidence's limits. + +The latest inspected producer and Simulator manifests both bind the baseline +SHA. Prepared OCaml 5.5.0/Dune 3.23.1 inputs and matching LUI package already exist; +do not relocate or repin them. Retain fresh source/provenance verification. The +current native Simulator route remains experimental host-restamped code with +`cross_abi_validated=false`; Simulator acceptance does not establish device ABI. + +Final required checks: `dune build @all app/native_embed.exe.o`, +`dune runtest --force`, decision-document validation, independent review and +affected native/GUI acceptance. Bind logs, manifests and recordings to final full +HEAD SHA, SDK, UDID, app hash and fixture inputs. Upload actual recordings through +Library, retain returned IDs, and distinguish upload from content readback. + +## Alternatives considered + +### Three existing full application/worker instances + +Each instance still owns its auth/catalog lifecycle and writes the same catalog +selection cache. This duplicates account authority, introduces account cleanup +races and does not meet the agreed sharing boundary. + +### Cache three screens while switching the existing worker + +This only changes appearance. Selection still retires the old websocket/DB and +interrupts saving. It cannot satisfy hidden synchronization or safe late callbacks. + +## Acceptance criteria + +- Complete every applicable scenario below on the final source revision; report + missing evidence explicitly rather than treating earlier source or mock UI as acceptance. + +| Scenario | Observable result / narrowest owner | +| --- | --- | +| One resident | No switch interception; normal Timeline interaction | +| Two / three residents | Right-only ordered loop, including final graph to first | +| Left swipe | No focus or runtime change | +| Right drag cancelled/reversed | Page returns; no committed focus change | +| Vertical scroll and other native gestures | No graph switch; scrolling/detail/composer/preview remain usable | +| Separate Capture and append drafts | Graph A's text/attachments never appear in B; returning restores A | +| Save in flight during focus change | Original graph commits once; no lost or duplicated Capture | +| Hidden graph remote update | Its scoped DB/queue advances while visible graph stays unchanged | +| Focus after hidden synchronization | Latest synchronized data is visible; no stale cached page | +| Hidden presentation cost | No hidden row-store/body updates for each sync event | +| Late response after focus | Completes its original live graph, never the visible one | +| Late response after close/reopen | Fenced by original lifetime; new session is untouched | +| Full three-slot membership | User chooses replacement; all other sessions remain running | +| Remove then restore | Local mirror remains; reopened graph receives fresh runtime scope | +| Restart | Resident order and last visible graph restored; old cache format migrates | +| Account logout/revocation | All owned sessions retire, no stale callback updates another account | +| UI delivery | Actual Simulator clips show success loop, drafts, hidden sync and cancellation; exact Library IDs retained | + +Prefer public pure reducer regressions for ownership, configuration and lifecycle. +Use service integration only for routing/coalescing boundaries not represented by +those reducers. GUI recordings supply the specifically requested visual evidence; +they do not replace source-level correctness checks. + +## Risks + +- Scope equality currently includes account presentation data. Visibility changes + must not accidentally expire still-resident graph scopes. +- Multiple pending graphs must survive bounded push coalescing; keeping only the + latest topic payload can silently lose hidden changes. +- Close/replace must preserve disk mirrors and pending outbox content while safely + releasing sessions and native file leases. +- Pending synchronization remains subject to actual iOS app lifecycle limits. +- This is a cross-layer ownership change; a UI-only partial PR is not complete. + +## Questions + +- Authorization resolved: the feature changes the two protected public contracts + `logseq_sync/spec/pure_reducer/core.mli` and + `logseq_db_worker/spec/pure_reducer/core.mli`, with the corresponding + implementations, consumers and tests described above. `AGENTS.md` explicitly + requires a request to change `.mli` contracts under `spec/`. The user explicitly authorized these two contracts on 2026-10-28 + (Sentinel_67314c7d73bc81918cebf4134793a9aa); no automatic-review denial occurred. No permission for additional + protected contracts, Dune changes, merging or release publishing is requested. + +### Current validation and remaining evidence + +- Full source build and `dune runtest --force` pass on the current implementation. + Coverage includes 344 sync tests, resident worker routing/teardown, per-scope + push coalescing and 150 application tests. +- A real iOS Simulator App builds through the maintained importer. It uses + experimental host-restamped OCaml, with `cross_abi_validated=false`. +- Independent review findings about hidden graph network permission, account + ledgers, shutdown, asset callback ownership and application retirement were + addressed. Independent read-only review of the final implementation diff + (`35ef9e6f1a2b671e94ccc33705792d7aa3bf08329f8cb22dd2572561c17a311f`) + reported no remaining P1/P2 in the reviewed scope. +- GUI interaction and recording are pending: the host Mac is locked and the + computer-use tool cannot unlock it. No simulator gesture or visual acceptance + is claimed. The task owns a separate synthetic-data Simulator; shared devices + remain untouched. +- The warm-start fixture blocks authentication, so it cannot by itself prove + hidden server synchronization. A recording of that scenario and save-in-flight + graph switching still require actual acceptance. Native scroll-position + retention across graph focus also remains unverified. + +### Real transport follow-up + +The first remote CI run for the initial PR head was cancelled before any step +executed because GitHub could not allocate a hosted macOS ARM64 runner. This +was a runner-capacity failure, not a source test result. + +A pinned-certificate loopback fixture subsequently reproduced an additional +physical routing defect: the socket table still used the constant diagnostic +string `start-websocket` as its key. It now uses the complete connection scope, +and running websocket operations have graph/connection-specific identities. +The regression verifies two real TLS sockets, separate baseline responses, a +hidden graph notification and its own Pull, and a successful B retry Pull/response after +A retires. This does not claim authoritative DB application or GUI acceptance. diff --git a/logseq_db_worker/lib/effect_runner/effect_runner.ml b/logseq_db_worker/lib/effect_runner/effect_runner.ml index 4b256eb7..64184e59 100644 --- a/logseq_db_worker/lib/effect_runner/effect_runner.ml +++ b/logseq_db_worker/lib/effect_runner/effect_runner.ml @@ -362,7 +362,8 @@ type change_window = } type database_session = - { database : Database.t + { graph_scope : Sync.graph_scope + ; database : Database.t ; subscription : Database.subscription ; mutable generation : string ; mutable next_window : int @@ -381,8 +382,8 @@ type t = ; asset_operations : (Logseq_sync_pure_reducer.Asset_transfer.ticket, string) Hashtbl.t ; mutable next_asset_operation : int64 ; upload_staging_lock : Eio.Mutex.t - ; mutable staging_reconciled : Sync.graph_scope option - ; local_imports : (Graph.Uuid.t * string, Graph.Uuid.t) Hashtbl.t + ; mutable staging_reconciled : Sync.graph_scope list + ; local_imports : (Sync.graph_scope * Graph.Uuid.t * string, Graph.Uuid.t) Hashtbl.t ; recovery_current : Core.upload_recovery_ticket -> bool ; upload_current : Upload.ticket -> bool ; asset_current : Logseq_sync_pure_reducer.Asset_transfer.ticket -> bool @@ -390,6 +391,8 @@ type t = ; inspections : (string, Database.mirror_inspection) Hashtbl.t ; waiters : (int64, waiter) Hashtbl.t ; waiter_lock : Eio.Mutex.t + ; attachments : (Sync.graph_scope, string) Hashtbl.t + ; session_snapshots : (Sync.graph_scope, Sync.snapshot) Hashtbl.t ; mutable attached : (string * Sync.graph_scope) option ; mutable next_database_id : int64 ; mutable stopped : bool @@ -420,12 +423,14 @@ let create ~sw dependencies ~post ~recovery_current ~upload_current ~asset_curre ; asset_operations = Hashtbl.create 8 ; next_asset_operation = 0L ; upload_staging_lock = Eio.Mutex.create () - ; staging_reconciled = None + ; staging_reconciled = [] ; local_imports = Hashtbl.create 32 ; databases = Hashtbl.create 4 ; inspections = Hashtbl.create 4 ; waiters = Hashtbl.create 32 ; waiter_lock = Eio.Mutex.create () + ; attachments = Hashtbl.create 3 + ; session_snapshots = Hashtbl.create 3 ; attached = None ; next_database_id = 0L ; stopped = false @@ -494,8 +499,12 @@ let current_graph_scope t (scope : Sync.graph_scope) = match t.latest_snapshot with | Some snapshot -> not t.stopped && snapshot.startup.account_generation = scope.account.account_generation - && snapshot.startup.graph_generation = scope.graph_generation - && snapshot.selected_graph = Some scope.graph_id + && (if snapshot.resident_graphs=[] then + snapshot.startup.graph_generation=scope.graph_generation && snapshot.selected_graph=Some scope.graph_id + else List.mem scope.graph_id snapshot.resident_graphs + && (match Hashtbl.find_opt t.session_snapshots scope with + | Some own -> own.selected_graph=Some scope.graph_id && own.startup.graph_generation=scope.graph_generation + | None -> Hashtbl.mem t.attachments scope)) | None -> false ;; @@ -609,20 +618,20 @@ let with_staging_lock t action = (fun () -> match action () with | result -> - if Result.is_error result then t.staging_reconciled <- None; + if Result.is_error result then t.staging_reconciled <- []; result | exception error -> - t.staging_reconciled <- None; + t.staging_reconciled <- []; raise error) ;; let reconcile_staging t (scope : Sync.graph_scope) ~current = if (not (current ())) || t.stopped then Error "Stale staging recovery" - else if t.staging_reconciled = Some scope + else if List.mem scope t.staging_reconciled then Ok () else ( - Hashtbl.clear t.local_imports; + Hashtbl.filter_map_inplace (fun (owner,_,_) operation -> if owner=scope then None else Some operation) t.local_imports; let keep = with_upload_store t (fun db -> let rec read after reversed = @@ -644,7 +653,7 @@ let reconcile_staging t (scope : Sync.graph_scope) ~current = let module I = Logseq_db_types.Asset_upload_intent in if intent.I.phase <> I.Cancelled then Hashtbl.replace t.local_imports - (intent.asset, intent.version.checksum) intent.operation_id; + (scope, intent.asset, intent.version.checksum) intent.operation_id; intent.staged_file :: files) reversed intents @@ -667,7 +676,7 @@ let reconcile_staging t (scope : Sync.graph_scope) ~current = | Ok (Sync.Asset_pruned _) -> if current () then ( - t.staging_reconciled <- Some scope; + t.staging_reconciled <- scope :: t.staging_reconciled; Ok ()) else Error "Stale staging recovery" | Error failure -> Error (asset_failure_message failure) @@ -944,8 +953,8 @@ let publish_projection_change t session = function ]) ^ "\n") with _ -> ()); t.post - (Core.Projection_push - (Protocol.V2_changes_available + (Core.Projection_push_for_scope + (session.graph_scope, Protocol.V2_changes_available { api_version = Protocol.api_version; generation; through = id })) | Overlay.Projection_resync_required { generation; reason; _ } -> let generation = Overlay.Generation.to_string generation in @@ -955,8 +964,8 @@ let publish_projection_change t session = function session.acknowledged_through <- Printf.sprintf "change-window:v1:%d" (session.next_window - 1); t.post - (Core.Projection_push - (Protocol.V2_resync_required_push + (Core.Projection_push_for_scope + (session.graph_scope, Protocol.V2_resync_required_push { api_version = Protocol.api_version ; generation ; reason = resync_reason reason @@ -1082,6 +1091,8 @@ let close_database_by_id ?scope t database_id = | _ -> ()) scope; report_warning "journal: operation=closeDatabase code=cleanupPending\n"); Hashtbl.remove t.databases database_id; + Hashtbl.filter_map_inplace (fun _ id -> if id=database_id then None else Some id) t.attachments; + (match t.attached with Some (id,_) when id=database_id -> t.attached <- None | _ -> ()); Result.map_error close_error result @@ -1770,10 +1781,7 @@ let sync_result ?event ?(lifecycle = Core.Lifecycle_unchanged) () = ;; let attached_session t scope = - match t.attached with - | Some (database_id, attached_scope) when attached_scope = scope -> - Hashtbl.find_opt t.databases database_id - | Some _ | None -> None + Option.bind (Hashtbl.find_opt t.attachments scope) (Hashtbl.find_opt t.databases) ;; let protected_envelope (iv, ciphertext) = @@ -2209,11 +2217,11 @@ let handle_sync_worker_effect t = function let scope : Sync.graph_scope = {account=request.account;graph_id=request.graph_id;graph_generation=0} in let result = try let* () = if current () then Ok () else Error "The graph account is no longer current." in - let* () = match t.attached with - | Some (_, actual) when actual.graph_id = request.graph_id -> + let* () = match Hashtbl.fold (fun (actual:Sync.graph_scope) _ found -> found || actual.graph_id=request.graph_id) t.attachments false with + | true -> report (failure_detail ~operation:"deleteMirror" ~reason:"busy" ()); Error "Switch to another graph before deleting." - | _ -> Ok () in + | false -> Ok () in let* () = with_staging_lock t (fun () -> recover_local_retirements ~on_diagnostic:report t scope) in let retirement = fresh_retirement () in (* Ownership is acquired before any asset retirement or intent mutation. *) @@ -2302,7 +2310,9 @@ let handle_sync_worker_effect t = function in Ok (sync_result ~event:(Sync.Mirror_deleted (request, result)) ()) | Attach_graph request -> - ignore (close_attached t); + (match t.latest_snapshot with + | Some snapshot when snapshot.resident_graphs<>[] -> () + | _ -> ignore (close_attached t)); let key = Graph.Uuid.to_string request.graph.graph_id in (match Hashtbl.find_opt t.inspections key with | None -> Error (effect_error "The graph attachment inspection is stale.") @@ -2322,7 +2332,8 @@ let handle_sync_worker_effect t = function let database_id = "managed-" ^ Int64.to_string t.next_database_id in t.next_database_id <- Int64.succ t.next_database_id; let session = - { database + { graph_scope = request.scope + ; database ; subscription ; generation = Overlay.Generation.to_string version.generation ; next_window = 1 @@ -2331,6 +2342,7 @@ let handle_sync_worker_effect t = function } in Hashtbl.replace t.databases database_id session; + Hashtbl.replace t.attachments request.scope database_id; t.attached <- Some (database_id, request.scope); (match Database.activate_subscription subscription ~notify:(fun change -> @@ -2357,10 +2369,9 @@ let handle_sync_worker_effect t = function ())))))) | Detach_graph scope -> let closed = - match t.attached with - | Some (_, attached) when attached = scope -> close_attached t + match Hashtbl.find_opt t.attachments scope with + | Some database_id -> close_database_by_id ~scope t database_id | None -> Ok () - | Some _ -> Error (effect_error "The graph close scope is stale.") in Result.map (fun () -> @@ -2370,14 +2381,11 @@ let handle_sync_worker_effect t = function ()) closed | Reset_managed_account account -> - let lifecycle = - match t.attached with - | Some (_, scope) when scope.account = account -> - ignore (close_attached t); - Core.Lifecycle_closed scope.graph_generation - | Some _ | None -> Core.Lifecycle_unchanged - in - Ok (sync_result ~lifecycle ()) + let owned=Hashtbl.fold (fun (scope:Sync.graph_scope) id result -> + if scope.account.user_id=account.user_id && scope.account.managed_sync_origin=account.managed_sync_origin + && scope.account.account_generation=account.account_generation then (scope,id)::result else result) t.attachments [] in + List.iter (fun (scope,id)->ignore(close_database_by_id ~scope t id)) owned; + Ok (sync_result ()) | Inspect_sync scope -> (match attached_session t scope with | None -> Error (effect_error "The overlay database is unavailable.") @@ -2479,10 +2487,7 @@ let run_request t (type a) (request : a Core.runner_request) Ok (execute_database ~on_enrichment_error:(fun () -> - match t.attached with - | Some (id, scope) when id=Core.database_handle_id database -> - report_current_graph t scope "worker.readTagTitles" - | _ -> ()) + report_current_graph t session.graph_scope "worker.readTagTitles") session ~response_budget_bytes:t.dependencies.config.response_budget_bytes request)) @@ -2699,7 +2704,7 @@ let local_import t ~(scope : Sync.graph_scope) ~asset ~checksum ~current = with_staging_lock t (fun () -> let ( let* ) = Result.bind in let* () = reconcile_staging t scope ~current in - match Hashtbl.find_opt t.local_imports (asset, checksum) with + match Hashtbl.find_opt t.local_imports (scope, asset, checksum) with | None -> Ok None | Some operation -> let* intent = @@ -2957,11 +2962,18 @@ let submit t = function t.dependencies.sync_runner.submit sync_effect | Run_sync sync_effect -> if not t.stopped then t.dependencies.sync_runner.submit sync_effect + | Publish (Core.Sync_output (Sync.Session_state_changed (scope,state)) as output) -> + if state.snapshot.selected_graph=Some scope.graph_id then Hashtbl.replace t.session_snapshots scope state.snapshot + else Hashtbl.remove t.session_snapshots scope; + if not t.stopped then t.dependencies.publish output | Publish (Core.Sync_output (Sync.State_changed state) as output) -> if not (Option.fold ~none:false ~some:(fun old -> old.Sync.catalog == state.snapshot.catalog) t.latest_snapshot) then t.authorized_graphs <- List.fold_left (fun ids (graph : Sync.graph) -> Authorized_graphs.add (Graph.Uuid.to_string graph.graph_id) ids) Authorized_graphs.empty state.snapshot.catalog; t.latest_snapshot <- Some state.snapshot; + Hashtbl.filter_map_inplace (fun (scope:Sync.graph_scope) own -> + if scope.account.account_generation=state.snapshot.startup.account_generation + && List.mem scope.graph_id state.snapshot.resident_graphs then Some own else None) t.session_snapshots; t.notice_diagnostics <- List.filter (fun item -> current_graph_scope t item.scope) t.notice_diagnostics; if not (Option.fold ~none:false ~some:(deletion_authorized t) t.deletion_owner) then t.deletion_owner <- None; @@ -3136,7 +3148,7 @@ let prepare_import t ~(context : Sync.asset_context) request ~current = (fun (intent : Logseq_db_types.Asset_upload_intent.t) -> Hashtbl.replace t.local_imports - (intent.asset, intent.version.checksum) + (context.scope, intent.asset, intent.version.checksum) intent.operation_id; if intent.phase <> Logseq_db_types.Asset_upload_intent.Cancelled && current () then diff --git a/logseq_db_worker/lib/logseq_db_worker.ml b/logseq_db_worker/lib/logseq_db_worker.ml index e616ee97..d83f09f5 100644 --- a/logseq_db_worker/lib/logseq_db_worker.ml +++ b/logseq_db_worker/lib/logseq_db_worker.ml @@ -204,7 +204,7 @@ let delete_local_copy t ~account_generation ~graph_id = if t.stopped || t.local_deletion_waiter <> None || not snapshot.startup.authenticated || snapshot.startup.account_generation <> account_generation - || snapshot.selected_graph = Some graph_id + || List.exists (fun (scope:Sync.graph_scope)->scope.graph_id=graph_id) (Pure_reducer.graph_scopes !(t.state)) || not (List.exists (fun (graph : Sync.graph) -> graph.graph_id = graph_id) snapshot.catalog) then Error "This local copy cannot be deleted now." else ( @@ -300,11 +300,9 @@ let import_asset t ~graph_generation request = in dispatch t - (Pure_reducer.Upload_requested - { graph_generation - ; operation = intent.Logseq_db_types.Asset_upload_intent.operation_id - ; event = Logseq_db_worker_pure_reducer.Asset_upload.Restore intent - }); + (Pure_reducer.Upload_requested_for_scope + (context.scope, intent.Logseq_db_types.Asset_upload_intent.operation_id, + Logseq_db_worker_pure_reducer.Asset_upload.Restore intent)); { operation = intent.operation_id ; graph_generation ; scope = context.scope @@ -321,3 +319,11 @@ let retain_imported_file t ~scope ~operation = then None else Effect_runner.retain_imported_file t.runner ~scope ~operation ;; + +let request_for_scope t ~scope request = + let id=Pure_reducer.request_id_of_int64 t.next_request_id in + t.next_request_id <- Int64.succ t.next_request_id; + Effect_runner.await_reply t.runner ~id ~request ~post:(fun () -> + post t (Pure_reducer.Graph_request_for_scope {scope;id;request})) +let graph_scopes t = Pure_reducer.graph_scopes !(t.state) +let graph_state_for_scope t ~scope = Pure_reducer.graph_state_for_scope !(t.state) scope diff --git a/logseq_db_worker/lib/logseq_db_worker.mli b/logseq_db_worker/lib/logseq_db_worker.mli index 48daee2a..63b19d64 100644 --- a/logseq_db_worker/lib/logseq_db_worker.mli +++ b/logseq_db_worker/lib/logseq_db_worker.mli @@ -87,3 +87,7 @@ val retain_imported_file -> scope:Logseq_sync_pure_reducer.Core.graph_scope -> operation:Logseq_db_types.Graph_types.Uuid.t -> (string * string) option + +val request_for_scope : t -> scope:Logseq_sync_pure_reducer.Core.graph_scope -> Protocol.request -> Protocol.response +val graph_scopes : t -> Logseq_sync_pure_reducer.Core.graph_scope list +val graph_state_for_scope : t -> scope:Logseq_sync_pure_reducer.Core.graph_scope -> graph_state option diff --git a/logseq_db_worker/lib/pure_reducer/core.ml b/logseq_db_worker/lib/pure_reducer/core.ml index 1dbcdde2..a14ead9a 100644 --- a/logseq_db_worker/lib/pure_reducer/core.ml +++ b/logseq_db_worker/lib/pure_reducer/core.ml @@ -93,6 +93,8 @@ type output = | Graph_push of Logseq_db_worker_contract.Protocol.push | Sync_output of Logseq_sync_pure_reducer.Core.output | Graph_state_changed of graph_state + | Scoped_graph_state_changed of Logseq_sync_pure_reducer.Core.graph_scope * graph_state + | Scoped_graph_push of Logseq_sync_pure_reducer.Core.graph_scope * Logseq_db_worker_contract.Protocol.push | Diagnostic of string type upload_recovery_ticket = @@ -131,9 +133,9 @@ let instruction_diagnostic = function "run-worker:Handle_sync_worker_effect" | Run_sync sync_effect -> "run-sync:" ^ Sync.runner_effect_diagnostic sync_effect | Publish (Reply _) -> "publish:reply" - | Publish (Graph_push _) -> "publish:graph-push" + | Publish (Graph_push _ | Scoped_graph_push _) -> "publish:graph-push" | Publish (Sync_output _) -> "publish:sync-output" - | Publish (Graph_state_changed _) -> "publish:graph-state" + | Publish (Graph_state_changed _ | Scoped_graph_state_changed _) -> "publish:graph-state" | Publish (Diagnostic _) -> "publish:diagnostic" ;; @@ -180,8 +182,25 @@ type pending = | Pending_execute of request_id * Logseq_db_worker_contract.Protocol.request * ticket | Pending_sync_worker of ticket * Sync.worker_effect +type graph_session = + { owner : Sync.graph_scope + ; saved_graph : graph_state + ; saved_assets : (Sync.asset_context * Logseq_sync_pure_reducer.Asset_transfer.t) option + ; saved_recovery : (Sync.graph_scope * Logseq_db_types.Graph_types.Uuid.t option * bool) option + ; saved_recovery_pending : upload_recovery_ticket option + ; saved_recovery_serial : int + ; saved_uploads : (Sync.asset_context * Logseq_db_types.Graph_types.Uuid.t * Asset_upload.t) list + ; saved_database : database_handle option + ; saved_deferred_detach : Sync.graph_scope option + ; saved_pending : pending list + } + type state = - { graph : graph_state + { account_reset : Sync.account_scope option + ; resident_mode : bool + ; active_scope : Sync.graph_scope option + ; graph_sessions : graph_session list + ; graph : graph_state ; sync_core : Sync.t ; assets : (Sync.asset_context * Logseq_sync_pure_reducer.Asset_transfer.t) option ; recovery : @@ -205,7 +224,11 @@ let initial config = Sync.initial config |> Result.map_error (fun (Sync.Invalid_create message) -> Invalid_create message) |> Result.map (fun sync_core -> - { graph = { generation = 0; graph_id = None; phase = Graph_closed; error = None } + { account_reset = None + ; resident_mode = false + ; active_scope = None + ; graph_sessions = [] + ; graph = { generation = 0; graph_id = None; phase = Graph_closed; error = None } ; sync_core ; assets = None ; recovery = None @@ -255,6 +278,14 @@ type event = | Asset_completed of Logseq_sync_pure_reducer.Core.graph_scope * Logseq_sync_pure_reducer.Asset_transfer.event + | Graph_request_for_scope of + { scope : Logseq_sync_pure_reducer.Core.graph_scope + ; id : request_id + ; request : Logseq_db_worker_contract.Protocol.request + } + | Projection_push_for_scope of Logseq_sync_pure_reducer.Core.graph_scope * Logseq_db_worker_contract.Protocol.push + | Asset_requested_for_scope of Logseq_sync_pure_reducer.Core.graph_scope * Logseq_sync_pure_reducer.Asset_transfer.event + | Upload_requested_for_scope of Logseq_sync_pure_reducer.Core.graph_scope * Logseq_db_types.Graph_types.Uuid.t * Asset_upload.event | Start | Graph_request of { id : request_id @@ -300,10 +331,72 @@ let unavailable_response request message = (worker_error ~code:Closed_session ~message) ;; + +let graph_pending = function + | Pending_sync_worker (_,(Sync.Reset_managed_account _ | Sync.Delete_mirror _)) -> false + | _ -> true +let freeze_graph owner state = + {owner + ; saved_graph = state.graph + ; saved_assets = state.assets + ; saved_recovery = state.recovery + ; saved_recovery_pending = state.recovery_pending + ; saved_recovery_serial = state.recovery_serial + ; saved_uploads = state.uploads + ; saved_database = state.database + ; saved_deferred_detach = state.deferred_detach + ; saved_pending = List.filter graph_pending state.pending + } +let remember_graph state = + let owner=match state.active_scope,state.graph.graph_id with + | Some scope,_ -> Some scope + | None,Some id -> (match Sync.scope_for_graph state.sync_core ~graph_id:id with + | Some scope when scope.graph_generation=state.graph.generation -> Some scope | _ -> None) + | None,None -> None in + match owner with + | None -> state + | Some owner -> {state with active_scope=Some owner; + graph_sessions=freeze_graph owner state :: List.filter (fun s->s.owner<>owner) state.graph_sessions} +let load_graph state (owner:Sync.graph_scope) = + let state=remember_graph state in + match List.find_opt (fun s->s.owner=owner) state.graph_sessions with + | Some saved -> {state with active_scope=Some owner + ; graph = saved.saved_graph + ; assets = saved.saved_assets + ; recovery = saved.saved_recovery + ; recovery_pending = saved.saved_recovery_pending + ; recovery_serial = saved.saved_recovery_serial + ; uploads = saved.saved_uploads + ; database = saved.saved_database + ; deferred_detach = saved.saved_deferred_detach + ; pending = List.filter (fun p->not(graph_pending p)) state.pending @ saved.saved_pending + } + | None -> {state with active_scope=Some owner; + graph={generation=owner.graph_generation;graph_id=Some owner.graph_id;phase=Graph_opening;error=None}; + assets=None;recovery=None;recovery_pending=None;recovery_serial=0;uploads=[]; + database=None;deferred_detach=None;pending=[]} +let restore_visible_graph state = + match Sync.admitted_graph_scope state.sync_core with + | Some scope -> load_graph state scope + | None -> state +let graph_publication state graph = + match state.active_scope with + | Some scope when state.resident_mode -> + Publish (Scoped_graph_state_changed (scope,graph)) + | _ -> Publish (Graph_state_changed graph) +let worker_effect_scope = function + | Sync.Inspect_mirror {scope;_} | Attach_graph {scope;_} | Detach_graph scope + | Inspect_sync scope | Apply_outbox_transition {scope;_} -> Some scope + | Activate_snapshot activation -> Some (Sync.snapshot_activation_scope activation) + | Apply_authoritative_batch {scope;_} -> Some scope.graph + | Reset_managed_account _ | Delete_mirror _ -> None +;; + let translate_sync transition state = - let state = { state with sync_core = transition.Sync.next } in + let resident_mode=state.resident_mode || (Sync.state transition.Sync.next).snapshot.resident_graphs<>[] in + let state = { (remember_graph state) with sync_core = transition.Sync.next;resident_mode } in let rec loop state reversed = function - | [] -> { next = state; effects = List.rev reversed } + | [] -> { next = remember_graph state; effects = List.rev reversed } | Sync.Run sync_effect :: rest -> loop state (Run_sync sync_effect :: reversed) rest | Sync.Publish (Sync.Protected_finished output) :: rest -> loop state (Deliver_protected_result output :: reversed) rest @@ -312,12 +405,27 @@ let translate_sync transition state = | Sync.Publish output :: rest -> loop state (Publish (Sync_output output) :: reversed) rest | Sync.Delegate (Sync.Detach_graph scope) :: rest -> + let state = if not state.resident_mode then state else load_graph state scope in let graph = { state.graph with phase = Graph_closing } in loop { state with graph; deferred_detach = Some scope } - (Publish (Graph_state_changed graph) :: reversed) + (graph_publication state graph :: reversed) rest + | Sync.Delegate (Sync.Reset_managed_account account) :: rest -> + let state=remember_graph state in + let sessions=List.map (fun session -> + if session.saved_database=None then session else + {session with saved_graph={session.saved_graph with phase=Graph_closing}; + saved_deferred_detach=Some session.owner}) state.graph_sessions in + let state={state with account_reset=Some account;graph_sessions=sessions} in + let state=match state.active_scope with + | None->state | Some scope -> + (match List.find_opt (fun s->s.owner=scope) sessions with + | None->state | Some session->{state with graph=session.saved_graph;deferred_detach=session.saved_deferred_detach}) in + loop state reversed rest | Sync.Delegate worker_effect :: rest -> + let state = if not state.resident_mode then state + else Option.fold ~none:state ~some:(load_graph state) (worker_effect_scope worker_effect) in let ticket, state = fresh_ticket state in let state = { state with @@ -340,15 +448,15 @@ let apply_lifecycle state = function { generation; graph_id = Some opened.graph_id; phase = Graph_open; error = None } in ( { state with graph; database = Some opened.database } - , [ Publish (Graph_state_changed graph) ] ) + , [ graph_publication state graph ] ) | Lifecycle_closed generation -> let graph = { generation; graph_id = None; phase = Graph_closed; error = None } in - { state with graph; database = None }, [ Publish (Graph_state_changed graph) ] + { state with graph; database = None }, [ graph_publication state graph ] | Lifecycle_failed (generation, error) -> let graph = { state.graph with generation; phase = Graph_failed; error = Some error } in - { state with graph; database = None }, [ Publish (Graph_state_changed graph) ] + { state with graph; database = None }, [ graph_publication state graph ] ;; let execute_completion state ticket result = @@ -374,7 +482,9 @@ let execute_completion state ticket result = match response with | Logseq_db_worker_contract.Protocol.V2_response { outcome = V2_mutation_committed _; _ } -> - translate_sync (Sync.step state.sync_core Sync.Local_outbox_changed) state + translate_sync (Sync.step state.sync_core (match state.active_scope with + | Some scope when state.resident_mode -> Sync.Local_graph_outbox_changed scope + | _ -> Sync.Local_outbox_changed)) state | V2_response _ -> no_effects state in { next = transitioned.next @@ -424,6 +534,8 @@ let step state event = | Upload_completed _ | Asset_requested _ | Asset_completed _ -> no_effects state + | Graph_request_for_scope _ | Projection_push_for_scope _ + | Asset_requested_for_scope _ | Upload_requested_for_scope _ -> no_effects state | Start -> no_effects state | Projection_push push -> if @@ -467,8 +579,10 @@ let step state event = | Runner_completed (Close_database_completed _) -> no_effects state | Runner_completed (Sync_worker_effect_completed (ticket, result)) -> if - ticket.generation <> state.graph.generation - || not (has_pending ticket.id state.pending) + not (has_pending ticket.id state.pending) + || (ticket.generation <> state.graph.generation && not (List.exists (function + | Pending_sync_worker (candidate,(Sync.Reset_managed_account _ | Sync.Delete_mirror _))->candidate.id=ticket.id + | _->false) state.pending)) then no_effects state else ( let worker_effect = @@ -594,14 +708,14 @@ let step state event = Their replies still finish; Sync rejects their superseded graph events. *) let step state event = let transition = step state event in - match transition.next.deferred_detach, transition.next.pending with + match transition.next.deferred_detach, List.filter graph_pending transition.next.pending with | Some scope, [] when not transition.next.shutdown -> let ticket, next = fresh_ticket transition.next in let worker_effect = Sync.Detach_graph scope in { next = { next with deferred_detach = None - ; pending = [ Pending_sync_worker (ticket, worker_effect) ] + ; pending = Pending_sync_worker (ticket, worker_effect) :: List.filter (fun p->not(graph_pending p)) next.pending } ; effects = transition.effects @@ -622,15 +736,19 @@ module Transfer = Logseq_sync_pure_reducer.Asset_transfer let asset_scope state = if state.shutdown || state.graph.phase <> Graph_open then None - else Sync.asset_context state.sync_core + else match state.active_scope with + | Some scope when state.resident_mode -> Sync.asset_context_for_scope state.sync_core scope + | _ -> Sync.asset_context state.sync_core ;; let asset_online state = + let sync=Option.value (Option.bind state.active_scope (Sync.state_for_scope state.sync_core)) + ~default:(Sync.state state.sync_core) in state.foreground - && (let f=(Sync.state state.sync_core).snapshot.startup in + && (let f=sync.snapshot.startup in f.authenticated && f.network_phase<>Sync.Local_only) && - match (Sync.state state.sync_core).snapshot.sync_phase with + match sync.snapshot.sync_phase with | Offline | Paused | Failed -> false | Connecting | Pulling | Submitting | Current -> true ;; @@ -1018,3 +1136,103 @@ let import_context state ~graph_generation = && List.length state.uploads + reserved < 32 -> Some context | _ -> None ;; + + +let step_session = step +let graph_scopes state = + List.filter_map (fun id -> Sync.scope_for_graph state.sync_core ~graph_id:id) + (Sync.resident_graphs state.sync_core) +let graph_state_for_scope state (scope:Sync.graph_scope) = + if Sync.scope_for_graph state.sync_core ~graph_id:scope.graph_id <> Some scope then None + else Some (load_graph state scope).graph +let session_for_ticket state ticket = + List.find_opt (fun s -> has_pending ticket.id s.saved_pending) (remember_graph state).graph_sessions +let scope_of_event state = function + | Graph_request_for_scope {scope;_} | Projection_push_for_scope (scope,_) + | Asset_requested_for_scope (scope,_) | Upload_requested_for_scope (scope,_,_) + | Asset_completed (scope,_) -> Some scope + | Upload_completed (ticket,_) -> Some ticket.Asset_upload.scope + | Uploads_loaded (ticket,_) -> Some ticket.scope + | Sync_event event -> Sync.event_graph_scope state.sync_core event + | Runner_completed (Execute_request_completed (ticket,_) | Close_database_completed (ticket,_) + | Sync_worker_effect_completed (ticket,_)) -> + Option.map (fun s->s.owner) (session_for_ticket state ticket) + | _ -> None +let step state event = + let state=match event with Sync_event (Sync.Resident_graphs_changed _) -> {state with resident_mode=true} | _->state in + let known=state.resident_mode in + let admitted (scope:Sync.graph_scope)=Sync.scope_for_graph state.sync_core ~graph_id:scope.Sync.graph_id=Some scope in + let owner=match event with + | Graph_request_for_scope {scope;_} | Projection_push_for_scope (scope,_) + | Asset_requested_for_scope (scope,_) | Upload_requested_for_scope (scope,_,_) + when not(admitted scope) -> None + | _ -> scope_of_event state event in + let owner=Option.bind owner (fun scope->if admitted scope || state.active_scope=Some scope + || List.exists(fun session->session.owner=scope) state.graph_sessions then Some scope else None) in + let state=match owner with Some scope when known->load_graph state scope | _->state in + let event=match event with + | Graph_request_for_scope {scope;id;request} -> + if Sync.scope_for_graph state.sync_core ~graph_id:scope.graph_id = Some scope + then Graph_request {id;request} + else Graph_request_for_scope {scope;id;request} + | Asset_requested_for_scope (scope,event) when admitted scope -> Asset_requested {graph_generation=scope.graph_generation;event} + | Upload_requested_for_scope (scope,operation,event) when admitted scope -> Upload_requested {graph_generation=scope.graph_generation;operation;event} + | Projection_push_for_scope (scope,push) when Sync.scope_for_graph state.sync_core ~graph_id:scope.graph_id=Some scope -> Projection_push push + | event -> event in + let transition=match event with + | Graph_request_for_scope {id;request;_} -> + {next=state;effects=[Publish (Reply (id,unavailable_response request "The graph scope is no longer open."))]} + | _ -> step_session state event in + let transition=match event with + | Set_foreground _ | Shutdown when known -> + let sessions=(remember_graph state).graph_sessions in + List.fold_left (fun result session -> + if state.active_scope=Some session.owner then result else + let loaded=load_graph result.next session.owner in + let loaded=if event=Shutdown then {loaded with shutdown=false} else loaded in + let part=step_session loaded (if event=Shutdown then Shutdown else Start) in + {next=remember_graph part.next;effects=result.effects @ part.effects}) transition sessions + | _->transition in + let next=remember_graph transition.next in + let next,drains=List.fold_left (fun (next,effects) session -> + if session.saved_deferred_detach<>None && session.saved_pending=[] then + let drained=step_session (load_graph next session.owner) Start in + remember_graph drained.next,effects @ drained.effects + else next,effects) (next,[]) next.graph_sessions in + let next,account_effects=match next.account_reset with + | Some account when List.for_all (fun session->session.saved_database=None + && session.saved_deferred_detach=None && session.saved_pending=[]) next.graph_sessions -> + let ticket,next=fresh_ticket next in + let request=Sync.Reset_managed_account account in + {next with account_reset=None;pending=Pending_sync_worker(ticket,request)::next.pending}, + [Run_worker(Request(ticket,Handle_sync_worker_effect request))] + | _->next,[] in + let effects=List.map (function + | Publish (Graph_push push) when known -> + (match owner with Some scope->Publish (Scoped_graph_push (scope,push)) | None->Publish (Graph_push push)) + | instruction -> instruction) (transition.effects @ drains @ account_effects) in + let next={next with graph_sessions=List.filter (fun session -> + session.saved_database<>None || session.saved_graph.phase<>Graph_closed + || session.saved_pending<>[] || session.saved_deferred_detach<>None) next.graph_sessions} in + let next=if next.graph.phase=Graph_closed && next.database=None && List.filter graph_pending next.pending=[] + then {next with active_scope=None} else next in + {next=(if known || (Sync.state next.sync_core).snapshot.resident_graphs<>[] then restore_visible_graph next else next);effects} +let asset_ticket_current_single = asset_ticket_current +let asset_ticket_current state ticket = + let scope=match ticket with Transfer.Lookup t->t.scope | Download t->t.scope in + asset_ticket_current_single (load_graph state scope) ticket +let asset_scope_current_single = asset_scope_current +let asset_scope_current state scope = asset_scope_current_single (load_graph state scope) scope +let upload_ticket_current_single = upload_ticket_current +let upload_ticket_current state (ticket:Asset_upload.ticket) = upload_ticket_current_single (load_graph state ticket.scope) ticket +let upload_recovery_current_single = upload_recovery_current +let upload_recovery_current state ticket = upload_recovery_current_single (load_graph state ticket.scope) ticket +let import_context_single = import_context +let import_context state ~graph_generation = + match List.find_opt (fun (scope:Sync.graph_scope)->scope.graph_generation=graph_generation) (graph_scopes state) with + | Some scope -> import_context_single (load_graph state scope) ~graph_generation + | None -> import_context_single state ~graph_generation +let import_context_for_scope state (scope:Sync.graph_scope) = + if Sync.scope_for_graph state.sync_core ~graph_id:scope.graph_id<>Some scope then None + else import_context_single (load_graph state scope) ~graph_generation:scope.graph_generation +;; diff --git a/logseq_db_worker/lui/logseq_db_worker_lui_service.ml b/logseq_db_worker/lui/logseq_db_worker_lui_service.ml index 6999de23..da6072d4 100644 --- a/logseq_db_worker/lui/logseq_db_worker_lui_service.ml +++ b/logseq_db_worker/lui/logseq_db_worker_lui_service.ml @@ -94,6 +94,8 @@ type snapshot = { sync_phase : sync_phase ; catalog : graph list ; selected_graph : graph_id option + ; resident_graphs : graph_id list + ; resident_scopes : Unlock.graph_scope list ; applied_server_t : Logseq_db_types.Server_cursor.t option ; timeline_presentation_pending : bool ; unlock : Unlock.unlock_phase @@ -166,6 +168,8 @@ let snapshot (value : Sync.snapshot) = { sync_phase = sync_phase value.sync_phase ; catalog = value.catalog ; selected_graph = value.selected_graph + ; resident_graphs = value.resident_graphs + ; resident_scopes = [] ; applied_server_t = value.applied_server_t ; timeline_presentation_pending = value.timeline_presentation_pending ; unlock = value.unlock @@ -209,6 +213,8 @@ type client_command = ; token : string } | Reject_token of token_request + | Configure_resident_graphs of graph_id list + | Focus_resident_graph of graph_id | Select_graph of Sync.graph_id | Return_to_graph_picker | Refresh_catalog of startup_scope @@ -282,6 +288,10 @@ type local_copy_request = } type request = + | Graph_request_at of timeline_scope * Logseq_db_worker.Protocol.request + | Get_graph_state_at of timeline_scope + | Asset_command_at of timeline_scope * asset_command + | Import_asset_at of timeline_scope * Logseq_db_types.Asset_import.t | Inspect_local_copy of local_copy_request | Delete_local_copy of local_copy_request | Import_asset of @@ -336,6 +346,9 @@ type asset_notice_batch = } type push = + | Graph_pushes of (asset_scope * Logseq_db_worker.Protocol.push) list + | Graph_states of (asset_scope * Logseq_db_worker.graph_state) list + | Session_states of (asset_scope * state) list | Asset_notices of asset_notice_batch | Graph_push of Protocol.push | Client_state_changed of state @@ -352,6 +365,7 @@ let auth_topic = ID.Worker.Push_topic.of_int 2 let bootstrap_topic = ID.Worker.Push_topic.of_int 3 let graph_state_topic = ID.Worker.Push_topic.of_int 4 let asset_topic = ID.Worker.Push_topic.of_int 5 +let session_topic = ID.Worker.Push_topic.of_int 6 let asset_notices = function | Asset_notice (scope, notice) -> [ scope, notice ] @@ -383,8 +397,17 @@ let add_asset_fact batch (scope, notice) = } ;; +let merge_scoped previous next = + let merged=List.fold_left (fun entries (scope,value) -> + List.filter (fun (owner,_) -> owner<>scope) entries @ [scope,value]) previous next in + if List.length merged>128 then failwith "Worker pending graph scope limit exceeded"; + merged let coalesce_push ~topic previous next = - if not (ID.Worker.Push_topic.equal topic asset_topic) + match previous,next with + | Graph_pushes previous,Graph_pushes next -> Graph_pushes (merge_scoped previous next) + | Graph_states previous,Graph_states next -> Graph_states (merge_scoped previous next) + | Session_states previous,Session_states next -> Session_states (merge_scoped previous next) + | _ -> if not (ID.Worker.Push_topic.equal topic asset_topic) then next else ( match previous, next with @@ -591,6 +614,7 @@ let diagnostic_asset_notice notice = let publish context = let latest : state option ref = ref None in + let resident_scopes = ref [] in let owner = ref None in let reports = ref [] in let with_reports (state : state) = @@ -627,6 +651,10 @@ let publish context = ~topic:asset_topic (Asset_notice (scope, notice)) | Pure.Reply _ -> () + | Pure.Scoped_graph_push (scope,push) -> + Journal_worker.Session_context.emit context ~topic:invalidation_topic (Graph_pushes [scope,push]) + | Pure.Scoped_graph_state_changed (scope,state) -> + Journal_worker.Session_context.emit context ~topic:graph_state_topic (Graph_states [scope,state]) | Graph_push push -> Journal_worker.Session_context.emit context @@ -636,6 +664,7 @@ let publish context = (match output with | State_changed state -> let state = client_state state in + let state = {state with snapshot={state.snapshot with resident_scopes= !resident_scopes}} in let scope = state.snapshot.startup.account_generation, state.snapshot.startup.graph_generation in if !owner <> Some scope then reports := []; owner := Some scope; @@ -646,6 +675,13 @@ let publish context = context ~topic:bootstrap_topic (Bootstrap_progress (bootstrap_progress progress)) + | Resident_scopes_changed scopes -> + resident_scopes:=scopes; + (match !latest with None->() | Some state-> + let state={state with snapshot={state.snapshot with resident_scopes=scopes}} in + latest:=Some state;emit state) + | Session_state_changed (scope,state) -> + Journal_worker.Session_context.emit context ~topic:session_topic (Session_states [scope,client_state state]) | Asset_finished _ | Protected_finished _ -> ()) | Graph_state_changed state -> Journal_worker.Session_context.emit @@ -713,6 +749,8 @@ let client_event = function | Local_account_unavailable (scope,reason) -> Pure.Sync_event (Sync.Local_account_unavailable (scope,reason)) | Provide_token _ | Reject_token _ -> invalid_arg "token commands are handled by the service" + | Configure_resident_graphs ids -> Pure.Sync_event (Sync.Resident_graphs_changed ids) + | Focus_resident_graph id -> Pure.Sync_event (Sync.Visible_graph_changed id) | Select_graph graph_id -> Pure.Sync_event (Sync.Graph_selected graph_id) | Return_to_graph_picker -> Pure.Sync_event Sync.Graph_picker_requested | Refresh_catalog scope -> Pure.Sync_event (Sync.Catalog_refresh_requested scope) @@ -758,7 +796,7 @@ let create ~(dependencies : dependencies) = end in Journal_worker.Service.create - ~push_topic_count:6 + ~push_topic_count:7 ~merge_push:coalesce_push ~concurrency:(Journal_worker.Service.Concurrent { max_in_flight = 2 }) ~data_directory:(fun config -> Ok config.Db.Config.application_support_directory) @@ -924,7 +962,30 @@ let create ~(dependencies : dependencies) = ; auth_wake })))) ~handle:(fun context session request -> + let resolve (owner:timeline_scope) = List.find_opt (fun (scope:Sync.graph_scope) -> + scope.account.account_generation=owner.startup.account_generation && scope.graph_id=owner.graph_id + && scope.graph_generation=owner.graph_generation) (Db.graph_scopes session.worker) in match request with + | Graph_request_at (owner,request) -> + (match resolve owner with + | Some scope -> Ok (Graph_response (Db.request_for_scope session.worker ~scope request)) + | None -> Error "The graph session is no longer open.") + | Get_graph_state_at owner -> + (match Option.bind (resolve owner) (fun scope->Db.graph_state_for_scope session.worker ~scope) with + | Some state -> Ok (Graph_state state) | None -> Error "The graph session is no longer open.") + | Import_asset_at (owner,source) -> + (match resolve owner with Some scope -> Ok (Asset_imported (Db.import_asset session.worker ~graph_generation:scope.graph_generation source)) + | None -> Error "The graph session is no longer open.") + | Asset_command_at (owner,command) -> + (match resolve owner with + | None -> Error "The graph session is no longer open." + | Some scope -> + let event=match command with + | Retry_upload operation -> Pure.Upload_requested_for_scope (scope,operation,Logseq_db_worker_pure_reducer.Asset_upload.Retry) + | Replace_asset_demand {consumer;priority;assets} -> Pure.Asset_requested_for_scope (scope,Logseq_sync_pure_reducer.Asset_transfer.Replace {consumer;priority;assets}) + | Release_asset_demand consumer -> Pure.Asset_requested_for_scope (scope,Release consumer) + | Retry_asset asset -> Pure.Asset_requested_for_scope (scope,Retry asset) in + Db.post session.worker event;Ok Client_command_completed) | Delete_local_copy request -> Eio.Condition.loop_no_mutex session.auth_wake (fun () -> if !(session.auth_enqueued_sequence) >= !(session.auth_sequence) then Some () else None); diff --git a/logseq_db_worker/lui/logseq_db_worker_lui_service.mli b/logseq_db_worker/lui/logseq_db_worker_lui_service.mli index 98ef4594..31ca64e6 100644 --- a/logseq_db_worker/lui/logseq_db_worker_lui_service.mli +++ b/logseq_db_worker/lui/logseq_db_worker_lui_service.mli @@ -96,6 +96,8 @@ type snapshot = { sync_phase : sync_phase ; catalog : graph list ; selected_graph : graph_id option + ; resident_graphs : graph_id list + ; resident_scopes : Unlock.graph_scope list ; applied_server_t : Logseq_db_types.Server_cursor.t option ; timeline_presentation_pending : bool ; unlock : Unlock.unlock_phase @@ -140,6 +142,8 @@ type client_command = ; token : string } | Reject_token of token_request + | Configure_resident_graphs of graph_id list + | Focus_resident_graph of graph_id | Select_graph of graph_id | Return_to_graph_picker | Refresh_catalog of startup_scope @@ -213,6 +217,10 @@ type local_copy_request = } type request = + | Graph_request_at of timeline_scope * Logseq_db_worker.Protocol.request + | Get_graph_state_at of timeline_scope + | Asset_command_at of timeline_scope * asset_command + | Import_asset_at of timeline_scope * Logseq_db_types.Asset_import.t | Inspect_local_copy of local_copy_request | Delete_local_copy of local_copy_request | Import_asset of @@ -251,6 +259,9 @@ type response = type asset_notice_batch type push = + | Graph_pushes of (asset_scope * Logseq_db_worker.Protocol.push) list + | Graph_states of (asset_scope * Logseq_db_worker.graph_state) list + | Session_states of (asset_scope * state) list | Asset_notices of asset_notice_batch | Graph_push of Logseq_db_worker.Protocol.push | Client_state_changed of state diff --git a/logseq_db_worker/spec/pure_reducer/core.mli b/logseq_db_worker/spec/pure_reducer/core.mli index 10119071..93f5db0e 100644 --- a/logseq_db_worker/spec/pure_reducer/core.mli +++ b/logseq_db_worker/spec/pure_reducer/core.mli @@ -91,6 +91,8 @@ type output = | Graph_push of Logseq_db_worker_contract.Protocol.push | Sync_output of Logseq_sync_pure_reducer.Core.output | Graph_state_changed of graph_state + | Scoped_graph_state_changed of Logseq_sync_pure_reducer.Core.graph_scope * graph_state + | Scoped_graph_push of Logseq_sync_pure_reducer.Core.graph_scope * Logseq_db_worker_contract.Protocol.push | Diagnostic of string type upload_recovery_ticket = private @@ -156,6 +158,14 @@ type event = | Asset_completed of Logseq_sync_pure_reducer.Core.graph_scope * Logseq_sync_pure_reducer.Asset_transfer.event + | Graph_request_for_scope of + { scope : Logseq_sync_pure_reducer.Core.graph_scope + ; id : request_id + ; request : Logseq_db_worker_contract.Protocol.request + } + | Projection_push_for_scope of Logseq_sync_pure_reducer.Core.graph_scope * Logseq_db_worker_contract.Protocol.push + | Asset_requested_for_scope of Logseq_sync_pure_reducer.Core.graph_scope * Logseq_sync_pure_reducer.Asset_transfer.event + | Upload_requested_for_scope of Logseq_sync_pure_reducer.Core.graph_scope * Logseq_db_types.Graph_types.Uuid.t * Asset_upload.event | Start | Graph_request of { id : request_id @@ -188,3 +198,8 @@ val import_context : state -> graph_generation:int -> Logseq_sync_pure_reducer.Core.asset_context option + +val graph_scopes : state -> Logseq_sync_pure_reducer.Core.graph_scope list + +val graph_state_for_scope : state -> Logseq_sync_pure_reducer.Core.graph_scope -> graph_state option +val import_context_for_scope : state -> Logseq_sync_pure_reducer.Core.graph_scope -> Logseq_sync_pure_reducer.Core.asset_context option diff --git a/logseq_db_worker/test/test_lui_service.ml b/logseq_db_worker/test/test_lui_service.ml index 6f64cc20..a4e6e525 100644 --- a/logseq_db_worker/test/test_lui_service.ml +++ b/logseq_db_worker/test/test_lui_service.ml @@ -1509,10 +1509,22 @@ let test_authoritative_detail_budget () = ;; + +let test_resident_invalidations_survive_coalescing () = + let scope=asset_scope 1 and other=asset_scope 2 in + let push n=Logseq_db_worker.Protocol.V2_changes_available {api_version=Logseq_db_worker.Protocol.api_version; + generation="generation:v1:1:1";through="change-window:v1:" ^ string_of_int n} in + let merged=Service.coalesce_push ~topic:Service.invalidation_topic + (Service.Graph_pushes [scope,push 1]) (Service.Graph_pushes [other,push 2]) in + T.require (merged=Service.Graph_pushes [scope,push 1;other,push 2]) "hidden graph invalidation was overwritten"; + let latest=Service.coalesce_push ~topic:Service.invalidation_topic merged (Service.Graph_pushes [scope,push 3]) in + T.require (latest=Service.Graph_pushes [other,push 2;scope,push 3]) "latest update must replace only its exact graph scope" +;; let () = T.run "lui worker service" - [ T.case "typed asset failures reach ledger" test_asset_failure_rows + [ T.case "resident invalidations preserve all graphs" test_resident_invalidations_survive_coalescing + ; T.case "typed asset failures reach ledger" test_asset_failure_rows ; T.case "safe worker failure rows" test_safe_worker_failure_rows ; T.case "authoritative integrity rows" test_authoritative_integrity_rows ; T.case "authoritative detail byte budget" test_authoritative_detail_budget diff --git a/logseq_db_worker/test/test_managed_sync_e2e.ml b/logseq_db_worker/test/test_managed_sync_e2e.ml index 7bfe5ead..25eef5d8 100644 --- a/logseq_db_worker/test/test_managed_sync_e2e.ml +++ b/logseq_db_worker/test/test_managed_sync_e2e.ml @@ -301,7 +301,9 @@ let handle_push context = function | Graph_state_changed state -> handle_graph_state context state | Asset_notice _ | Asset_notices _ -> fail_worker context "assets" "unexpected asset notice without demand" - | Graph_push _ -> context.last_event <- "graph-push" + | Graph_push _ | Graph_pushes _ -> context.last_event <- "graph-push" + | Graph_states states -> List.iter (fun (_,state)->handle_graph_state context state) states + | Session_states _ -> () ;; let handle_event context = function diff --git a/logseq_db_worker/test/test_pure_reducer.ml b/logseq_db_worker/test/test_pure_reducer.ml index 048da7eb..a0b8d3fb 100644 --- a/logseq_db_worker/test/test_pure_reducer.ml +++ b/logseq_db_worker/test/test_pure_reducer.ml @@ -1191,10 +1191,134 @@ let test_authoritative_failure_categories_reach_diagnostics () = ; Unsupported_semantics, "authoritativeDecodeFailed", "Authoritative transaction format is unsupported." ] ;; + +let resident_worker_fixture () = + let ready, first_scope = worker_open_graph () in + let first_id=first_scope.Sync.graph_id in + let second_id=uuid "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" in + let snapshot=(Core.view ready).sync.snapshot in + let second : Sync.graph = {(List.hd snapshot.catalog) with graph_id=second_id;name="Second synthetic graph"} in + let requested=Core.step ready (Sync_event (Sync.Catalog_refresh_requested (startup_scope ready))) in + let fetched=List.find_map (function + | Core.Run_sync (Sync.Request (ticket,Sync.Fetch_catalog _)) -> + Some (Core.Sync_event (Sync.Runner_completed (Sync.Completion (ticket,Ok (snapshot.catalog @ [second]))))) + | _ -> None) requested.effects |> Option.get in + let catalog=Core.step requested.next fetched in + let configured=Core.step catalog.next (Sync_event (Sync.Resident_graphs_changed [first_id;second_id])) in + let rec open_requests core = function + | [] -> core + | Core.Run_worker (Core.Request (ticket,Core.Handle_sync_worker_effect (Sync.Inspect_mirror request))) :: rest -> + let next=Core.step core (Runner_completed (Sync_worker_effect_completed (ticket, + Ok {event=Some (Sync.Mirror_inspected (Mirror_available request));lifecycle=Lifecycle_unchanged}))) in + open_requests next.next (next.effects @ rest) + | Core.Run_worker (Core.Request (ticket,Core.Handle_sync_worker_effect (Sync.Attach_graph request))) :: rest -> + let database_id="resident-" ^ Logseq_db_types.Graph_types.Uuid.to_string request.scope.graph_id in + let database=Core.database_opened ~database_id ~graph_id:request.scope.graph_id in + let module O=Logseq_overlay_db.Types in + let sync=O.sync_view ~token:(O.sync_token_of_string "sync-token:v1:resident" |> Result.get_ok) + ~checkpoint:(O.Server_cursor.of_int64 0L |> Result.get_ok) ~submissions:[] in + let next=Core.step core (Runner_completed (Sync_worker_effect_completed (ticket, + Ok {event=Some (Sync.Graph_attached {scope=request.scope;sync}); + lifecycle=Lifecycle_opened (database,request.scope.graph_generation)}))) in + open_requests next.next rest + | _ :: rest -> open_requests core rest in + let opened=open_requests configured.next configured.effects in + opened,first_scope,second_id +;; +let test_resident_worker_routes_by_scope () = + let opened,first_scope,second_id=resident_worker_fixture () in + Alcotest.(check int) "two independent DB scopes remain open" 2 (List.length (Core.graph_scopes opened)); + let second_scope=List.find (fun (scope:Sync.graph_scope)->scope.graph_id=second_id) (Core.graph_scopes opened) in + let request=graph_info_request () in + let started=Core.step opened (Graph_request_for_scope {scope=first_scope;id=Core.request_id_of_int64 101L;request}) in + let execution=List.find_map (function Core.Run_worker ((Core.Request (_,Execute_request _)) as instruction)->Some instruction | _->None) started.effects |> Option.get in + let focused=Core.step started.next (Sync_event (Sync.Visible_graph_changed second_id)) in + let hidden=Core.step focused.next (Graph_request_for_scope {scope=first_scope;id=Core.request_id_of_int64 102L;request}) in + Alcotest.(check bool) "hidden request still targets first database" true + (List.exists (function Core.Run_worker (Core.Request (_,Execute_request {database;_}))-> + Core.database_handle_id database="db-delete" | _->false) hidden.effects); + let second=Core.step hidden.next (Graph_request_for_scope {scope=second_scope;id=Core.request_id_of_int64 103L;request}) in + Alcotest.(check bool) "visible request targets its own database" true + (List.exists (function Core.Run_worker (Core.Request (_,Execute_request {database;_}))-> + Core.database_handle_id database="resident-" ^ Logseq_db_types.Graph_types.Uuid.to_string second_id | _->false) second.effects); + let completion=Core.complete_execute execution (Error (Logseq_db_worker.Error.create + ~code:Closed_session ~message:"Synthetic terminal read." ~details:[] |> Result.get_ok)) |> Option.get in + let completed=Core.step second.next completion in + Alcotest.(check bool) "late completion replies to original request exactly once" true + (List.exists (function Core.Publish (Reply (id,_))->Core.request_id_to_int64 id=101L | _->false) completed.effects); + let duplicate=Core.step completed.next completion in + Alcotest.(check bool) "duplicate completion cannot reply twice" false + (List.exists (function Core.Publish (Reply _)->true | _->false) duplicate.effects) +;; + +let test_resident_worker_closes_every_scope () = + List.iter (fun event -> + let opened,_,_=resident_worker_fixture () in + let closing=Core.step opened event in + let detached=List.filter_map (function + | Core.Run_worker (Core.Request (ticket,Handle_sync_worker_effect (Sync.Detach_graph scope))) -> Some(ticket,scope) + | _->None) closing.effects in + Alcotest.(check int) "every removed DB independently drains and detaches" 2 (List.length detached); + let terminal=List.fold_left (fun state (ticket,scope)-> + (Core.step state (Runner_completed (Sync_worker_effect_completed (ticket, + Ok {event=Some(Sync.Graph_detached(scope,Ok()));lifecycle=Lifecycle_closed scope.graph_generation})))).next) + closing.next detached in + Alcotest.(check int) "no removed graph remains admitted" 0 (List.length(Core.graph_scopes terminal))) + [Core.Sync_event(Sync.Resident_graphs_changed []);Core.Sync_event(Sync.Account_authenticated{user_id=None})] +let test_resident_worker_shutdown_closes_all_databases () = + let opened,_,_=resident_worker_fixture () in + let stopped=Core.step opened Shutdown in + let databases=List.filter_map (function + | Core.Run_worker(Core.Request(_,Close_database database))->Some(Core.database_handle_id database) + | _->None) stopped.effects in + Alcotest.(check int) "shutdown closes both resident databases" 2 (List.length(List.sort_uniq String.compare databases)) +;; +let test_resident_worker_cold_restore_keeps_open_tickets () = + List.iter(fun count-> + let graphs=List.init count(fun n->({graph_id=uuid(Printf.sprintf "65000000-0000-4000-8000-%012d" (n+1));name="Cached synthetic"; + schema={major=65;minor=33;exact=true};encrypted=false}:Sync.graph)) in + let ids=List.map(fun(g:Sync.graph)->g.graph_id) graphs in + let selected=List.nth ids (count-1) in + let cache=Sync.catalog_cache ~user_id:"cached-resident" ~graphs ~selected_graph:(Some selected) + |> Sync.encode_catalog_cache |> Yojson.Safe.from_string |> Yojson.Safe.Util.to_assoc in + let cache=`Assoc(("cache_resident_graphs",`List(List.map(fun id->`String(Logseq_db_types.Graph_types.Uuid.to_string id)) ids))::cache) + |> Yojson.Safe.to_string |> Sync.decode_catalog_cache |> Result.get_ok in + let restoring=Core.step (raw_initial ()) (Sync_event(Sync.Restore_local_account{user_id="cached-resident"})) in + let completion=List.find_map(function Core.Run_sync(Sync.Request(ticket,Sync.Load_catalog _))-> + Some(Core.Sync_event(Sync.Runner_completed(Sync.Completion(ticket,Ok(Some cache)))))|_->None) restoring.effects |> Option.get in + let restored=Core.step restoring.next completion in + let inspections=List.filter_map(function Core.Run_worker(Core.Request(ticket,Handle_sync_worker_effect(Sync.Inspect_mirror request)))->Some(ticket,request)|_->None) restored.effects in + Alcotest.(check int) "one cold local inspection per resident" count (List.length inspections); + let opened=List.fold_left(fun state (ticket,(request:Sync.mirror_request))-> + let attaching=Core.step state (Runner_completed(Sync_worker_effect_completed(ticket, + Ok{event=Some(Sync.Mirror_inspected(Mirror_available request));lifecycle=Lifecycle_unchanged}))) in + let attachment=List.find_map(function Core.Run_worker(Core.Request(ticket,Handle_sync_worker_effect(Sync.Attach_graph _)))->Some ticket|_->None) attaching.effects in + Alcotest.(check bool) "cached inspection ticket survives session restore" true (Option.is_some attachment); + let database=Core.database_opened ~database_id:("cached-" ^ Logseq_db_types.Graph_types.Uuid.to_string request.scope.graph_id) ~graph_id:request.scope.graph_id in + let module O=Logseq_overlay_db.Types in + let sync=O.sync_view ~token:(O.sync_token_of_string "sync-token:v1:cached" |> Result.get_ok) + ~checkpoint:(O.Server_cursor.of_int64 0L |> Result.get_ok) ~submissions:[] in + (Core.step attaching.next (Runner_completed(Sync_worker_effect_completed(Option.get attachment, + Ok{event=Some(Sync.Graph_attached{scope=request.scope;sync});lifecycle=Lifecycle_opened(database,request.scope.graph_generation)})))).next) + restored.next (List.rev inspections) in + Alcotest.(check bool) "cold restore preserves last focused graph" true ((Core.view opened).sync.snapshot.selected_graph=Some selected); + List.iter(fun scope-> + Alcotest.(check bool) "each restored graph really opens" true + (Option.fold ~none:false ~some:(fun(g:Core.graph_state)->g.phase=Core.Graph_open) (Core.graph_state_for_scope opened scope)); + let read=Core.step opened (Graph_request_for_scope{scope;id=Core.request_id_of_int64 901L;request=graph_info_request()}) in + Alcotest.(check bool) "restored scope executes against its own DB" true + (List.exists(function Core.Run_worker(Core.Request(_,Execute_request{database;_}))-> + Core.database_handle_id database="cached-" ^ Logseq_db_types.Graph_types.Uuid.to_string scope.graph_id|_->false) read.effects)) (Core.graph_scopes opened)) [1;2;3] +;; + let () = Alcotest.run "logseq db worker pure reducer" - [ ( "authoritative diagnostics", [Alcotest.test_case "four categories propagate without closing graph" `Quick test_authoritative_failure_categories_reach_diagnostics]) + [ ( "resident graphs", [Alcotest.test_case "cold cached membership opens every DB" `Quick test_resident_worker_cold_restore_keeps_open_tickets; + Alcotest.test_case "scope routes and delayed replies survive focus" `Quick test_resident_worker_routes_by_scope; + Alcotest.test_case "all removals and logout detach each graph" `Quick test_resident_worker_closes_every_scope; + Alcotest.test_case "shutdown closes every resident database" `Quick test_resident_worker_shutdown_closes_all_databases]) + ; ( "authoritative diagnostics", [Alcotest.test_case "four categories propagate without closing graph" `Quick test_authoritative_failure_categories_reach_diagnostics]) ; ( "snapshot failure", [ Alcotest.test_case "terminal failure and explicit retry" `Quick test_snapshot_failure_is_terminal_and_retryable; Alcotest.test_case "success and late graph switch completions" `Quick test_snapshot_success_and_late_switch_completions ]) diff --git a/logseq_sync/lib/effect_runner/effect_runner.ml b/logseq_sync/lib/effect_runner/effect_runner.ml index 0ddc4a34..db9e42ca 100644 --- a/logseq_sync/lib/effect_runner/effect_runner.ml +++ b/logseq_sync/lib/effect_runner/effect_runner.ml @@ -367,9 +367,9 @@ type t = ; operations : (string, operation) Hashtbl.t ; submitted_operations : Submitted_effects.t ; keys : (string, key_entry) Hashtbl.t - ; websockets : (string, Core.connection_scope * Websocket_eio.t) Hashtbl.t - ; mutable websocket_owner : websocket_owner option - ; mutable last_retired_websocket : Core.connection_scope option + ; websockets : (Core.connection_scope, Websocket_eio.t) Hashtbl.t + ; websocket_owners : (Core.graph_scope, websocket_owner) Hashtbl.t + ; mutable retired_websockets : Core.connection_scope list ; asset_download_slots : Eio.Semaphore.t ; asset_upload_slots : Eio.Semaphore.t ; asset_codec_slot : Eio.Semaphore.t @@ -394,8 +394,8 @@ let create ?(on_asset_diagnostic=(fun ~scope:_ ~operation:_ ~request_operation:_ ; submitted_operations = Submitted_effects.create 32 ; keys = Hashtbl.create 8 ; websockets = Hashtbl.create 4 - ; websocket_owner = None - ; last_retired_websocket = None + ; websocket_owners = Hashtbl.create 3 + ; retired_websockets = [] ; asset_download_slots = Eio.Semaphore.make 3 ; asset_upload_slots = Eio.Semaphore.make 1 ; asset_codec_slot = Eio.Semaphore.make 1 @@ -923,8 +923,8 @@ let zeroize bytes = Bytes.fill bytes 0 (Bytes.length bytes) '\000' let retire_websockets ?(remove = true) t matches retire = let selected = Hashtbl.fold - (fun key (scope, websocket) selected -> - if matches scope then (key, websocket) :: selected else selected) + (fun scope websocket selected -> + if matches scope then (scope, websocket) :: selected else selected) t.websockets [] in @@ -932,8 +932,8 @@ let retire_websockets ?(remove = true) t matches retire = List.iter (fun (_, websocket) -> retire websocket) selected ;; -let retire_websocket_owner ?(abort = false) t scope = - match t.websocket_owner with +let retire_websocket_owner ?(abort = false) t (scope:Core.connection_scope) = + match Hashtbl.find_opt t.websocket_owners scope.Core.graph with | Some owner when owner.physical_scope = scope -> if abort then ( @@ -953,12 +953,12 @@ let retire_websocket_owner ?(abort = false) t scope = (try Eio.Promise.await owner.finished with | Eio.Cancel.Cancelled _ -> Eio.Cancel.protect (fun () -> Eio.Promise.await owner.finished)); - (match t.websocket_owner with - | Some current when current == owner -> t.websocket_owner <- None + (match Hashtbl.find_opt t.websocket_owners scope.graph with + | Some current when current == owner -> Hashtbl.remove t.websocket_owners scope.graph | _ -> ()); - t.last_retired_websocket <- Some scope; + t.retired_websockets <- scope :: List.take 31 (List.filter ((<>) scope) t.retired_websockets); if not t.closed then t.post (Core.Websocket_retired scope))) - | _ when t.last_retired_websocket = Some scope -> () + | _ when List.mem scope t.retired_websockets -> () | _ -> t.dependencies.runtime.fork ~sw:t.sw (fun () -> if not t.closed then t.post (Core.Websocket_retired scope)) @@ -990,14 +990,11 @@ let cancel_scope ?(network_only = false) t scope = connection_generation = Some connection.connection_generation }) t.dependencies.transport.abort_websocket; - match t.websocket_owner with - | Some owner - when scope_matches - scope - { (Core.effect_scope_of_graph owner.physical_scope.graph) with - connection_generation = Some owner.physical_scope.connection_generation - } -> retire_websocket_owner ~abort:true t owner.physical_scope - | _ -> () + Hashtbl.to_seq_values t.websocket_owners |> List.of_seq |> List.iter (fun owner -> + if scope_matches scope + { (Core.effect_scope_of_graph owner.physical_scope.graph) with + connection_generation=Some owner.physical_scope.connection_generation } + then retire_websocket_owner ~abort:true t owner.physical_scope) ;; let unlock_request_kind : type a. a Core.runner_request -> string option = function @@ -1149,7 +1146,7 @@ let submit_nonasset t instruction = if (not t.closed) && not operation.cancelled then t.post (Core.Timer_elapsed request.id)) | Start_websocket request -> - (match t.websocket_owner with + (match Hashtbl.find_opt t.websocket_owners request.scope.graph with | Some owner when owner.physical_scope = request.scope -> () | Some _ -> t.dependencies.runtime.fork ~sw:t.sw (fun () -> @@ -1160,7 +1157,11 @@ let submit_nonasset t instruction = ( request.scope , Core.Protocol_failure "Previous WebSocket owner has not retired" ))) | None -> - let key = Core.runner_effect_diagnostic instruction in + let scope=request.scope in + let key=Printf.sprintf "websocket:%d:%s:%d:%d" + scope.graph.account.account_generation + (Logseq_db_types.Graph_types.Uuid.to_string scope.graph.graph_id) + scope.graph.graph_generation scope.connection_generation in let cancelled, resolve_cancelled = Eio.Promise.create () in let finished, resolve_finished = Eio.Promise.create () in let pending = ref None in @@ -1183,7 +1184,7 @@ let submit_nonasset t instruction = ; retirement_requested = false } in - t.websocket_owner <- Some owner; + Hashtbl.replace t.websocket_owners request.scope.graph owner; Hashtbl.replace t.operations key operation; t.dependencies.runtime.fork ~sw:t.sw (fun () -> let registered = ref false in @@ -1212,7 +1213,7 @@ let submit_nonasset t instruction = | Error error -> t.post (Core.Websocket_protocol_error (request.scope, error)))) ~on_close:(fun termination -> - Hashtbl.remove t.websockets key; + Hashtbl.remove t.websockets request.scope; if (not t.closed) && not operation.cancelled then Eio.Fiber.first @@ -1269,7 +1270,7 @@ let submit_nonasset t instruction = t.dependencies.transport.activate_websocket handle ~on_open:(fun () -> - Hashtbl.replace t.websockets key (request.scope, handle); + Hashtbl.replace t.websockets request.scope handle; registered := true; t.post (Core.Websocket_opened request.scope)) with @@ -1289,7 +1290,7 @@ let submit_nonasset t instruction = | Ok payload -> let sent = ref false in Hashtbl.iter - (fun _ (scope, websocket) -> + (fun scope websocket -> if scope = request.scope then ( sent := true; diff --git a/logseq_sync/lib/pure_reducer/core.ml b/logseq_sync/lib/pure_reducer/core.ml index f0374648..af710d1a 100644 --- a/logseq_sync/lib/pure_reducer/core.ml +++ b/logseq_sync/lib/pure_reducer/core.ml @@ -153,6 +153,7 @@ type snapshot = { sync_phase : sync_phase ; catalog : graph list ; selected_graph : graph_id option + ; resident_graphs : graph_id list ; applied_server_t : Server_cursor.t option ; timeline_presentation_pending : bool ; unlock : unlock_phase @@ -262,6 +263,7 @@ type catalog_cache = { cache_user_id : string ; cache_graphs : graph list ; cache_selected_graph : graph_id option + ; cache_resident_graphs : graph_id list option [@default None] } [@@deriving yojson] @@ -307,12 +309,15 @@ let catalog_cache ~user_id ~graphs ~selected_graph = { cache_user_id = user_id ; cache_graphs = graphs ; cache_selected_graph = selected_graph + ; cache_resident_graphs = None } ;; let catalog_cache_user_id cache = cache.cache_user_id let catalog_cache_graphs cache = cache.cache_graphs let catalog_cache_selected_graph cache = cache.cache_selected_graph +let catalog_cache_resident_graphs cache = + Option.value cache.cache_resident_graphs ~default:(Option.to_list cache.cache_selected_graph) let encode_catalog_cache cache = catalog_cache_to_yojson cache |> Yojson.Safe.to_string let decode_catalog_cache source = @@ -332,6 +337,14 @@ let decode_catalog_cache source = | Ok cache -> if (not (bounded_text cache.cache_user_id)) + || (match cache.cache_resident_graphs with + | None -> false + | Some ids -> List.length ids > 3 + || List.length (List.sort_uniq compare ids) <> List.length ids + || List.exists (fun id -> not (List.exists (fun (graph:graph) -> graph.graph_id=id) cache.cache_graphs)) ids + || (match cache.cache_selected_graph with + | Some id -> not (List.mem id ids) + | None -> ids <> [])) || List.exists (fun (graph : graph) -> (not (bounded_text graph.name)) @@ -714,6 +727,8 @@ type output = | Asset_finished of asset_output | Protected_finished of protected_output | State_changed of state + | Resident_scopes_changed of graph_scope list + | Session_state_changed of graph_scope * state | Bootstrap_progressed of bootstrap_progress type instruction = @@ -779,6 +794,9 @@ type event = | Account_authenticated of { user_id : string option } | Local_feed_acknowledged | Local_outbox_changed + | Resident_graphs_changed of graph_id list + | Visible_graph_changed of graph_id + | Local_graph_outbox_changed of graph_scope | Timeline_presented of timeline_scope | Graph_selected of graph_id | Graph_picker_requested @@ -854,6 +872,7 @@ type websocket_owner = } module Asset_retirement_set = Map.Make (String) +module Pending_id_set = Set.Make (Int) let asset_retirement_key (scope : graph_scope) retirement = Printf.sprintf "%S:%S:%S:%S" (Uri.to_string scope.account.managed_sync_origin) scope.account.user_id (Logseq_db_types.Graph_types.Uuid.to_string scope.graph_id) @@ -865,8 +884,55 @@ type unlock_preparation = | Fetching_graph_key of graph_scope * effect_id | Fetching_user_keys of graph_scope * string * effect_id +type graph_session = + { saved_identity : graph_scope + ; saved_snapshot : snapshot + ; saved_diagnostics : diagnostics + ; saved_pending_effects : pending_effect list + ; saved_asset_retirement_observed : unit Asset_retirement_set.t + ; saved_asset_known_scopes : graph_scope list + ; saved_protected_pending : protected_ticket list + ; saved_assets_pending : pending_asset list + ; saved_selected_graph_value : graph option + ; saved_current_graph_scope : graph_scope option + ; saved_connection_generation : connection_generation + ; saved_graph_key : graph_key_handle option + ; saved_pending_graph_open : mirror_request option + ; saved_pending_mirror_inspection : mirror_request option + ; saved_pending_selected_download : graph_scope option + ; saved_pending_attachment : mirror_request option + ; saved_unlock_preparation : unlock_preparation + ; saved_bootstrap : bootstrap + ; saved_sync_view : Overlay.sync_view option + ; saved_pending_sync_inspection : graph_scope option + ; saved_pending_outbox_transition : outbox_transition_request option + ; saved_submission_owner : submission_owner option + ; saved_pull_owner : pull_owner option + ; saved_pull_wanted : (graph_scope * Server_cursor.t) option + ; saved_force_pull_pending : bool + ; saved_diagnostic_remote_cursor : (graph_scope * Server_cursor.t) option + ; saved_pull_retry_delay : float + ; saved_active_authoritative_batch : authoritative_batch option + ; saved_queued_authoritative_batch : authoritative_batch option + ; saved_deferred_authoritative_owner : Overlay.submission_batch_id option + ; saved_websocket_live : bool + ; saved_foreground : bool + ; saved_foreground_generation : lifecycle_generation option + ; saved_websocket_owner : websocket_owner option + ; saved_wanted_websocket : websocket_request option + ; saved_reconnect_timer : (timer_id * connection_scope) option + ; saved_reconnect_pending : bool + ; saved_reconnect_delay : float + ; saved_closed : bool + } + type t = - { asset_retirement_observed : unit Asset_retirement_set.t + { resident_order : graph_id list + ; resident_sessions : graph_session list + ; resident_next_generation : int + ; resident_enabled : bool + ; restored_residents : graph_id list option + ; asset_retirement_observed : unit Asset_retirement_set.t ; asset_known_scopes : graph_scope list ; protected_pending : protected_ticket list ; assets_pending : pending_asset list @@ -937,6 +1003,7 @@ let initial config = ; sync_phase = Offline ; catalog = [] ; selected_graph = None + ; resident_graphs = [] ; applied_server_t = None ; timeline_presentation_pending = false ; unlock = Unlock_inactive @@ -946,7 +1013,12 @@ let initial config = } in Ok - { asset_retirement_observed = Asset_retirement_set.empty + { resident_order = [] + ; resident_sessions = [] + ; resident_next_generation = 0 + ; resident_enabled = false + ; restored_residents = None + ; asset_retirement_observed = Asset_retirement_set.empty ; asset_known_scopes = [] ; protected_pending = [] ; assets_pending = [] @@ -1272,6 +1344,7 @@ let save_cache core = ~graphs:core.public_state.snapshot.catalog ~selected_graph:core.cached_selected_graph in + let cache = if core.resident_enabled then { cache with cache_resident_graphs = Some core.resident_order } else cache in let next, request = issue_request core (Save_catalog { account = account_scope core user_id; cache }) in @@ -1481,7 +1554,7 @@ let is_graph_key_access (Pending_effect ticket) = let select_graph_transition core ~persist graph_id = match core.user_id, graph_in_catalog core.public_state.snapshot.catalog graph_id with | Some user_id, Some graph -> - let graph_generation = core.public_state.snapshot.startup.graph_generation + 1 in + let graph_generation = max core.resident_next_generation core.public_state.snapshot.startup.graph_generation + 1 in let startup = { core.public_state.snapshot.startup with awaiting_selection = false @@ -1511,6 +1584,8 @@ let select_graph_transition core ~persist graph_id = set_snapshot { (reset_graph_operations core) with cached_selected_graph = Some graph_id + ; resident_next_generation = graph_generation + ; websocket_live = false ; pending_effects = List.filter (fun pending -> not (is_graph_key_access pending)) core.pending_effects ; selected_graph_value = Some graph ; current_graph_scope = Some scope @@ -2523,6 +2598,7 @@ let clear_selected_graph_for_catalog core catalog = { local_deletion = None ; sync_phase = Offline ; catalog + ; resident_graphs = [] ; selected_graph = None ; applied_server_t = None ; timeline_presentation_pending = false @@ -2628,7 +2704,8 @@ let complete_catalog_load core cache = in let loaded = set_snapshot - { core with catalog_cache_loading = false; cached_selected_graph } + { core with catalog_cache_loading = false; cached_selected_graph + ; restored_residents = Option.bind cache (fun cache -> cache.cache_resident_graphs) } { core.public_state.snapshot with catalog; selected_graph = None; startup } in let restored = @@ -3313,6 +3390,7 @@ let step core event = | _ -> submission_timeout ()) | _ -> submission_timeout ()) | Asset_requested _ | Protected_requested _ -> unchanged core + | Resident_graphs_changed _ | Visible_graph_changed _ | Local_graph_outbox_changed _ | Local_feed_acknowledged -> unchanged core | Shutdown -> let scope = @@ -4031,3 +4109,359 @@ let step core event = in { next; effects } ;; + + +(* Resident sessions use the existing graph policy as a lens. Account/catalog + fields and the global effect serial are never stored in graph_session. *) +let graph_pending (Pending_effect ticket) = ticket.scope.graph_generation <> None +let freeze_session identity core = + { saved_identity = identity + ; saved_snapshot = core.public_state.snapshot + ; saved_diagnostics = core.public_state.diagnostics + ; saved_pending_effects = List.filter graph_pending core.pending_effects + ; saved_asset_retirement_observed = core.asset_retirement_observed + ; saved_asset_known_scopes = core.asset_known_scopes + ; saved_protected_pending = core.protected_pending + ; saved_assets_pending = core.assets_pending + ; saved_selected_graph_value = core.selected_graph_value + ; saved_current_graph_scope = core.current_graph_scope + ; saved_connection_generation = core.connection_generation + ; saved_graph_key = core.graph_key + ; saved_pending_graph_open = core.pending_graph_open + ; saved_pending_mirror_inspection = core.pending_mirror_inspection + ; saved_pending_selected_download = core.pending_selected_download + ; saved_pending_attachment = core.pending_attachment + ; saved_unlock_preparation = core.unlock_preparation + ; saved_bootstrap = core.bootstrap + ; saved_sync_view = core.sync_view + ; saved_pending_sync_inspection = core.pending_sync_inspection + ; saved_pending_outbox_transition = core.pending_outbox_transition + ; saved_submission_owner = core.submission_owner + ; saved_pull_owner = core.pull_owner + ; saved_pull_wanted = core.pull_wanted + ; saved_force_pull_pending = core.force_pull_pending + ; saved_diagnostic_remote_cursor = core.diagnostic_remote_cursor + ; saved_pull_retry_delay = core.pull_retry_delay + ; saved_active_authoritative_batch = core.active_authoritative_batch + ; saved_queued_authoritative_batch = core.queued_authoritative_batch + ; saved_deferred_authoritative_owner = core.deferred_authoritative_owner + ; saved_websocket_live = core.websocket_live + ; saved_foreground = core.foreground + ; saved_foreground_generation = core.foreground_generation + ; saved_websocket_owner = core.websocket_owner + ; saved_wanted_websocket = core.wanted_websocket + ; saved_reconnect_timer = core.reconnect_timer + ; saved_reconnect_pending = core.reconnect_pending + ; saved_reconnect_delay = core.reconnect_delay + ; saved_closed = core.closed + } +let restore_session core session = + let shared = core.public_state.snapshot in + let snapshot = session.saved_snapshot in + let snapshot = {snapshot with catalog=shared.catalog;resident_graphs=core.resident_order; + startup={snapshot.startup with authenticated=shared.startup.authenticated; + account_generation=shared.startup.account_generation;catalog_loading=shared.startup.catalog_loading; + presentation_generation=max shared.startup.presentation_generation snapshot.startup.presentation_generation}} in + {core with public_state={snapshot;diagnostics=session.saved_diagnostics}; + pending_effects = List.filter (fun p -> not (graph_pending p)) core.pending_effects @ session.saved_pending_effects + ; asset_retirement_observed = session.saved_asset_retirement_observed + ; asset_known_scopes = session.saved_asset_known_scopes + ; protected_pending = session.saved_protected_pending + ; assets_pending = session.saved_assets_pending + ; selected_graph_value = session.saved_selected_graph_value + ; current_graph_scope = session.saved_current_graph_scope + ; connection_generation = session.saved_connection_generation + ; graph_key = session.saved_graph_key + ; pending_graph_open = session.saved_pending_graph_open + ; pending_mirror_inspection = session.saved_pending_mirror_inspection + ; pending_selected_download = session.saved_pending_selected_download + ; pending_attachment = session.saved_pending_attachment + ; unlock_preparation = session.saved_unlock_preparation + ; bootstrap = session.saved_bootstrap + ; sync_view = session.saved_sync_view + ; pending_sync_inspection = session.saved_pending_sync_inspection + ; pending_outbox_transition = session.saved_pending_outbox_transition + ; submission_owner = session.saved_submission_owner + ; pull_owner = session.saved_pull_owner + ; pull_wanted = session.saved_pull_wanted + ; force_pull_pending = session.saved_force_pull_pending + ; diagnostic_remote_cursor = session.saved_diagnostic_remote_cursor + ; pull_retry_delay = session.saved_pull_retry_delay + ; active_authoritative_batch = session.saved_active_authoritative_batch + ; queued_authoritative_batch = session.saved_queued_authoritative_batch + ; deferred_authoritative_owner = session.saved_deferred_authoritative_owner + ; websocket_live = session.saved_websocket_live + ; foreground = session.saved_foreground + ; foreground_generation = session.saved_foreground_generation + ; websocket_owner = session.saved_websocket_owner + ; wanted_websocket = session.saved_wanted_websocket + ; reconnect_timer = session.saved_reconnect_timer + ; reconnect_pending = session.saved_reconnect_pending + ; reconnect_delay = session.saved_reconnect_delay + ; closed = session.saved_closed + } +let remember_session identity core = + let saved = freeze_session identity core in + {core with resident_sessions=saved :: List.filter (fun s -> s.saved_identity <> identity) core.resident_sessions} +let remember_current core = + match core.current_graph_scope with None -> core | Some identity -> remember_session identity core +let resident_graphs core = + if core.resident_enabled then core.resident_order else Option.to_list core.public_state.snapshot.selected_graph +let scope_for_graph core ~graph_id = + if not core.resident_enabled then Option.bind core.current_graph_scope + (fun scope -> if scope.graph_id=graph_id then Some scope else None) + else if not (List.mem graph_id core.resident_order) then None + else List.find_map (fun session -> + if session.saved_identity.graph_id=graph_id && session.saved_current_graph_scope=Some session.saved_identity + then Some session.saved_identity else None) (remember_current core).resident_sessions +let state_for_scope core (scope : graph_scope) = + if scope_for_graph core ~graph_id:scope.graph_id <> Some scope then None + else if core.current_graph_scope=Some scope then Some core.public_state + else Option.map (fun session -> (restore_session core session).public_state) + (List.find_opt (fun s -> s.saved_identity=scope) core.resident_sessions) +let asset_context_for_scope core (scope : graph_scope) = + if scope_for_graph core ~graph_id:scope.graph_id <> Some scope then None + else if core.current_graph_scope=Some scope then asset_context core + else Option.bind (List.find_opt (fun s -> s.saved_identity=scope) core.resident_sessions) + (fun session -> asset_context (restore_session core session)) +let session_for_effect core (scope : effect_scope) = + List.find_opt (fun session -> + let s=session.saved_identity in + (scope.graph_generation=Some s.graph_generation && scope.account_generation=Some s.account.account_generation) + || List.exists (fun (Pending_effect ticket)->ticket.scope=scope) session.saved_pending_effects) + (remember_current core).resident_sessions +let event_graph_scope core = function + | Asset_requested ({scope;action;_} as request) when asset_cleanup_action action -> + (match List.find_opt (fun session->List.mem scope session.saved_asset_known_scopes) + (remember_current core).resident_sessions with + | Some session->Some session.saved_identity | None->Some request.scope) + | Local_graph_outbox_changed scope | Asset_requested {scope;_} + | Protected_requested {protected_scope=scope;_} | Graph_detached (scope,_) + | Mirror_inspected (Mirror_absent scope) -> Some scope + | Mirror_inspected (Mirror_available {scope;_}) | Graph_attached {scope;_} + | Sync_inspected {scope;_} | Outbox_transition_applied {scope;_} + | Authoritative_batch_applied {scope;_} | Authoritative_batch_deferred {scope;_} + | Outbox_transition_failed {request={scope;_};_} -> Some scope + | Snapshot_activated activation -> Some activation.scope + | Snapshot_activation_failed error -> Some error.activation.scope + | Websocket_opened {graph;_} | Websocket_message ({graph;_},_) + | Websocket_protocol_error ({graph;_},_) | Websocket_closed ({graph;_},_) -> Some graph + | Websocket_retired connection -> + (match List.find_opt (fun session->Option.fold ~none:false + ~some:(fun owner->owner.physical_scope=connection) session.saved_websocket_owner) + (remember_current core).resident_sessions with + | Some session->Some session.saved_identity | None->Some connection.graph) + | E2ee_password_submitted (challenge,_) -> Some challenge.challenge_scope + | Graph_attachment_failed {scope;_} | Authoritative_batch_failed {scope;_} -> + Option.map (fun s -> s.saved_identity) (session_for_effect core scope) + | Runner_completed (Completion (ticket,_)) -> + List.find_map (fun session->if List.exists (fun (Pending_effect pending)-> + pending.id=ticket.id && pending.scope=ticket.scope) session.saved_pending_effects + then Some session.saved_identity else None) (remember_current core).resident_sessions + | Runner_completed (Asset_completion (ticket,_)) -> + List.find_map (fun session->if List.exists (fun pending->pending.asset_ticket=ticket) session.saved_assets_pending + then Some session.saved_identity else None) (remember_current core).resident_sessions + | Runner_completed (Protected_completion (ticket,_)) -> + List.find_map (fun session->if List.mem ticket session.saved_protected_pending + then Some session.saved_identity else None) (remember_current core).resident_sessions + | Timeline_presented scope | Online_recovery_requested scope -> scope_for_graph core ~graph_id:scope.graph_id + | Timer_elapsed id -> + List.find_map (fun s -> + let timer = Option.fold ~none:false ~some:(fun (timer,_) -> timer=id) s.saved_reconnect_timer + || Option.fold ~none:false ~some:(fun owner -> owner.response_timer=Some id) s.saved_submission_owner + || Option.fold ~none:false ~some:(fun owner -> match owner.pull_stage with + | Awaiting_pull_response timer | Awaiting_pull_retry timer -> timer=id | Applying_pull -> false) s.saved_pull_owner in + if timer then Some s.saved_identity else None) (remember_current core).resident_sessions + | _ -> None +let step_graph_policy = step +let scoped_publications (scope : graph_scope) effects = List.map (function + | Publish (State_changed state) -> Publish (Session_state_changed (scope,state)) + | instruction -> instruction) effects +let visible_identity core = core.public_state.snapshot.selected_graph +let restore_visible core id = + match id with + | Some id -> (match List.find_opt (fun s -> s.saved_identity.graph_id=id + && List.mem id core.resident_order && s.saved_current_graph_scope=Some s.saved_identity) core.resident_sessions with + | Some session -> restore_session core session | None -> core) + | None -> core +let run_session core (identity : graph_scope) event = + let core=remember_current core in + match List.find_opt (fun s -> s.saved_identity=identity) core.resident_sessions with + | None -> unchanged core + | Some session -> + let visible=visible_identity core in + let shared=core.public_state.snapshot in + let transition=step_graph_policy (restore_session core session) event in + let snapshot=transition.next.public_state.snapshot in + let snapshot={snapshot with catalog=shared.catalog; + startup={snapshot.startup with authenticated=shared.startup.authenticated; + catalog_loading=shared.startup.catalog_loading;account_generation=shared.startup.account_generation}} in + let _,account_pending=List.fold_left (fun (ids,pending) (Pending_effect ticket as obligation)-> + if graph_pending obligation || Pending_id_set.mem ticket.id ids then ids,pending + else Pending_id_set.add ticket.id ids,obligation::pending) + (Pending_id_set.empty,[]) (core.pending_effects @ transition.next.pending_effects) in + let updated={transition.next with public_state={transition.next.public_state with snapshot}; + pending_effects=List.rev account_pending @ List.filter graph_pending transition.next.pending_effects} in + let fresh=Option.value updated.current_graph_scope ~default:identity in + let updated=if fresh=identity then updated else {updated with + resident_sessions=List.filter(fun session->session.saved_identity<>identity) updated.resident_sessions} in + let next=remember_session fresh updated |> fun core -> restore_visible core visible in + {next;effects=if visible=Some fresh.graph_id then transition.effects + else scoped_publications fresh transition.effects} +let empty_session core = + let blank=initial core.config |> Result.get_ok in + let identity=Option.get core.current_graph_scope in + let empty=freeze_session identity blank in + let next=restore_session core empty in + {next with public_state={next.public_state with snapshot={next.public_state.snapshot with + startup={next.public_state.snapshot.startup with + graph_generation=core.resident_next_generation;network_phase=core.public_state.snapshot.startup.network_phase}}}} +let valid_residents core ids = + List.length ids<=3 && List.length (List.sort_uniq compare ids)=List.length ids + && List.for_all (fun id -> Option.is_some (graph_in_catalog core.public_state.snapshot.catalog id)) ids +let configure_residents core ids = + if not (valid_residents core ids) then unchanged core else + let old_visible=visible_identity core in + let core=remember_current {core with resident_enabled=true} in + let removed=List.filter (fun s -> List.mem s.saved_identity.graph_id core.resident_order + && not (List.mem s.saved_identity.graph_id ids)) core.resident_sessions in + let core={core with resident_order=ids} in + let core,effects=List.fold_left (fun (core,effects) session -> + let closed=step_graph_policy (restore_session core session) Graph_picker_requested in + let next=remember_session session.saved_identity closed.next in + next,effects @ scoped_publications session.saved_identity closed.effects) (core,[]) removed in + let core,effects=List.fold_left (fun (core,effects) id -> + if Option.is_some (scope_for_graph core ~graph_id:id) then core,effects else + let base = match core.current_graph_scope with + | Some _ -> empty_session core + | None -> let blank=initial core.config |> Result.get_ok in + let base={blank with user_id=core.user_id;next_effect_id=core.next_effect_id; + resident_order=core.resident_order;resident_enabled=true;resident_sessions=core.resident_sessions; + resident_next_generation=core.resident_next_generation;foreground=core.foreground; + lifecycle_generation=core.lifecycle_generation;public_state=core.public_state} in + {base with public_state={base.public_state with snapshot={base.public_state.snapshot with + startup={base.public_state.snapshot.startup with graph_generation=core.resident_next_generation}}}} in + let opened=select_graph_transition base ~persist:false id in + let snapshot=opened.next.public_state.snapshot in + let opened={opened with next={opened.next with public_state={opened.next.public_state with + snapshot={snapshot with startup={snapshot.startup with network_phase=base.public_state.snapshot.startup.network_phase}}}}} in + let scope=Option.get opened.next.current_graph_scope in + let next=remember_current {opened.next with resident_next_generation=scope.graph_generation} in + next,effects @ scoped_publications scope opened.effects) (core,effects) ids in + let visible=match old_visible with Some id when List.mem id ids -> Some id | _ -> List.nth_opt ids 0 in + let next=restore_visible core visible in + let snapshot={next.public_state.snapshot with selected_graph=visible;resident_graphs=ids} in + let next={next with cached_selected_graph=visible;public_state={next.public_state with snapshot}} in + let next,saved=save_cache next in + {next;effects=effects @ [publish next] @ saved} +let resume_resident_network core = + let shared=core.public_state.snapshot in + if shared.startup.network_phase=Local_only then unchanged core else + let original=visible_identity core in + let sessions=(remember_current core).resident_sessions in + let next,effects=List.fold_left (fun (core,effects) session -> + if not (List.mem session.saved_identity.graph_id core.resident_order) then core,effects else + let loaded=restore_session core session |> fun c -> network_phase c shared.startup.network_phase in + let resumed = if not shared.startup.authenticated then unchanged loaded else + match loaded.current_graph_scope,loaded.sync_view with + | Some scope,Some _ when not loaded.websocket_live && (match loaded.websocket_owner with + | None | Some {phase=Retiring;_} -> true | Some _ -> false) -> enforce_websocket_owner loaded (start_websocket loaded scope) + | Some scope,None when loaded.pending_selected_download=Some scope && loaded.public_state.snapshot.startup.bootstrapping -> + begin_missing_mirror_bootstrap loaded scope + | Some scope,None when loaded.public_state.snapshot.startup.local_access=Local_unavailable Local_key_unavailable + && loaded.public_state.snapshot.startup.failure=Some During_local_restore -> begin_e2ee_key_access loaded scope + | _ -> unchanged loaded in + let next=remember_session session.saved_identity resumed.next in + next,effects @ (if original=Some session.saved_identity.graph_id then resumed.effects + else scoped_publications session.saved_identity resumed.effects)) (remember_current core,[]) sessions in + {next=restore_visible next original;effects} +let prune_sessions core = + {core with resident_sessions=List.filter (fun s -> + List.mem s.saved_identity.graph_id core.resident_order || s.saved_current_graph_scope<>None + || s.saved_websocket_owner<>None || s.saved_pending_effects<>[] + || s.saved_assets_pending<>[] || s.saved_protected_pending<>[]) core.resident_sessions} +let step core event = + match event with + | Resident_graphs_changed ids -> configure_residents core ids + | Graph_selected id when core.resident_enabled && List.mem id core.resident_order -> + let focused=restore_visible (remember_current core) (Some id) in + (match scope_for_graph focused ~graph_id:id with + | Some identity->run_session focused identity event | None->unchanged core) + | Visible_graph_changed id when List.mem id (resident_graphs core) -> + let core=remember_current core in + let next=restore_visible core (Some id) in + let next={next with cached_selected_graph=Some id} in + let next,effects=save_cache next in + {next;effects=publish next :: effects} + | Visible_graph_changed _ -> unchanged core + | Local_cache_deletion_requested id when List.mem id (resident_graphs core) -> unchanged core + | _ when not core.resident_enabled -> + let transition=step_graph_policy core event in + let next={transition.next with resident_next_generation=max core.resident_next_generation transition.next.public_state.snapshot.startup.graph_generation} in + (match next.restored_residents with + | Some ids when ids<>[] -> + let restored=configure_residents {next with restored_residents=None} ids in + {restored with effects=transition.effects @ restored.effects} + | _ -> {transition with next}) + | Local_graph_outbox_changed scope -> run_session core scope Local_outbox_changed + | Account_authenticated {user_id} when user_id=None || user_id<>core.user_id -> + let retired=List.filter (fun s -> List.mem s.saved_identity.graph_id core.resident_order) (remember_current core).resident_sessions in + let closes=List.concat_map (fun s -> (step_graph_policy (restore_session core s) Graph_picker_requested).effects + |> scoped_publications s.saved_identity) retired in + let signed_out=step_graph_policy core event in + {next={signed_out.next with resident_order=[];resident_sessions=[]; + public_state={signed_out.next.public_state with snapshot={signed_out.next.public_state.snapshot with resident_graphs=[]}}}; + effects=closes @ signed_out.effects} + | Online_cancelled scope when startup_scope_is_current core scope -> + let visible=visible_identity core in + let sessions=(remember_current core).resident_sessions in + let next,effects=List.fold_left(fun(core,effects) session-> + let loaded=restore_session core session in + let startup=loaded.public_state.snapshot.startup in + let part=step_graph_policy loaded (Online_cancelled + {account_generation=startup.account_generation;presentation_generation=startup.presentation_generation}) in + remember_session session.saved_identity part.next, + effects @ (if visible=Some session.saved_identity.graph_id then part.effects + else scoped_publications session.saved_identity part.effects)) (remember_current core,[]) sessions in + let next=restore_visible next visible in + let effects=List.filter (function Publish(State_changed _)->false | _->true) effects in + {next;effects=effects @ [publish next]} + | Foreground_changed _ | Shutdown -> + let sessions=(remember_current core).resident_sessions in + let transition=List.fold_left (fun transition s -> + let part=run_session transition.next s.saved_identity event in + {next=part.next;effects=transition.effects @ part.effects}) (unchanged core) sessions in + if event=Shutdown then {transition with next={transition.next with resident_order=[]; + public_state={transition.next.public_state with snapshot={transition.next.public_state.snapshot with resident_graphs=[];selected_graph=None}}}} + else transition + | _ -> + match event_graph_scope core event with + | Some identity -> run_session core identity event + | None -> + let transition=step_graph_policy core event in + let next=match core.current_graph_scope,transition.next.current_graph_scope with + | Some old,Some fresh when old<>fresh && old.graph_id=fresh.graph_id -> + remember_current {transition.next with resident_sessions=List.filter + (fun session->session.saved_identity<>old) transition.next.resident_sessions} + | _->remember_current transition.next in + let resumed=resume_resident_network next in + let transition={next=resumed.next;effects=transition.effects @ resumed.effects} in + let next=transition.next in + (* Catalog/authentication have one authority. Revoke removed sessions. *) + let ids=List.filter (fun id -> Option.is_some (graph_in_catalog next.public_state.snapshot.catalog id)) next.resident_order in + if ids<>next.resident_order then + let reconciled=configure_residents next ids in + {reconciled with effects=transition.effects @ reconciled.effects} + else {transition with next} +;; + +let step core event = let transition=step core event in + {transition with next=prune_sessions transition.next} + +let step core event = + let transition=step core event in + let scopes c=List.filter_map (fun id->scope_for_graph c ~graph_id:id) (resident_graphs c) in + let changed=transition.next.resident_enabled && scopes core<>scopes transition.next in + if changed then {transition with effects=transition.effects @ [Publish(Resident_scopes_changed(scopes transition.next))]} + else transition +;; diff --git a/logseq_sync/spec/pure_reducer/core.mli b/logseq_sync/spec/pure_reducer/core.mli index 6968b546..07e4879a 100644 --- a/logseq_sync/spec/pure_reducer/core.mli +++ b/logseq_sync/spec/pure_reducer/core.mli @@ -102,6 +102,7 @@ type snapshot = { sync_phase : sync_phase ; catalog : graph list ; selected_graph : graph_id option + ; resident_graphs : graph_id list ; applied_server_t : Logseq_db_types.Server_cursor.t option ; timeline_presentation_pending : bool ; unlock : unlock_phase @@ -523,6 +524,8 @@ type output = | Asset_finished of asset_output | Protected_finished of protected_output | State_changed of state + | Resident_scopes_changed of graph_scope list + | Session_state_changed of graph_scope * state | Bootstrap_progressed of bootstrap_progress type instruction = @@ -590,6 +593,9 @@ type event = | Account_authenticated of { user_id : string option } | Local_feed_acknowledged | Local_outbox_changed + | Resident_graphs_changed of graph_id list + | Visible_graph_changed of graph_id + | Local_graph_outbox_changed of graph_scope | Timeline_presented of timeline_scope | Graph_selected of graph_id | Graph_picker_requested @@ -648,3 +654,12 @@ type transition = } val step : t -> event -> transition + +(** Ordered, currently resident graphs; at most three unique authorized entries. *) +val resident_graphs : t -> graph_id list +val scope_for_graph : t -> graph_id:graph_id -> graph_scope option +val catalog_cache_resident_graphs : catalog_cache -> graph_id list + +val state_for_scope : t -> graph_scope -> state option +val asset_context_for_scope : t -> graph_scope -> asset_context option +val event_graph_scope : t -> event -> graph_scope option diff --git a/logseq_sync/test/core_contract.ml b/logseq_sync/test/core_contract.ml index f692119b..41735d5c 100644 --- a/logseq_sync/test/core_contract.ml +++ b/logseq_sync/test/core_contract.ml @@ -6260,3 +6260,353 @@ let catalog_save_keeps_the_safe_producer_cause () = let scenarios = scenarios @ [Alcotest.test_case "catalog save retains safe producer cause" `Quick catalog_save_keeps_the_safe_producer_cause] ;; + +(* Resident membership is independent of graph lifetime and presentation. *) +let third_graph = { other_graph with graph_id = + Logseq_db_types.Graph_types.Uuid.of_string "33333333-3333-4333-8333-333333333333" |> Result.get_ok } +let fourth_graph = { other_graph with graph_id = + Logseq_db_types.Graph_types.Uuid.of_string "44444444-4444-4444-8444-444444444444" |> Result.get_ok } +let resident_catalog () = + let selected, _ = selected_graph graph in + (refresh_catalog selected.next [graph;other_graph;third_graph;fourth_graph]).next +let resident_scope core id = Core.scope_for_graph core ~graph_id:id |> Option.get +let resident_ids = [graph.graph_id;other_graph.graph_id;third_graph.graph_id] +let check_residents expected core = + Alcotest.(check bool) "configured resident order" true (Core.resident_graphs core = expected) +let resident_membership_limits_and_order () = + let original = resident_catalog () in + let one = Core.step original (Core.Resident_graphs_changed [graph.graph_id]) in + check_residents [graph.graph_id] one.next; + let two = Core.step one.next (Core.Resident_graphs_changed [graph.graph_id;other_graph.graph_id]) in + check_residents [graph.graph_id;other_graph.graph_id] two.next; + let three = Core.step two.next (Core.Resident_graphs_changed resident_ids) in + check_residents resident_ids three.next; + List.iter (fun invalid -> + let result = Core.step three.next (Core.Resident_graphs_changed invalid) in + check_residents resident_ids result.next; + check_instructions "invalid configuration emits no work" [] result.effects) + [resident_ids @ [fourth_graph.graph_id];[graph.graph_id;graph.graph_id];[fourth_graph.graph_id;Logseq_db_types.Graph_types.Uuid.of_string "55555555-5555-4555-8555-555555555555" |> Result.get_ok]]; + let reordered = Core.step three.next (Core.Resident_graphs_changed (List.rev resident_ids)) in + check_residents (List.rev resident_ids) reordered.next +let resident_focus_preserves_lifetimes () = + let configured = Core.step (resident_catalog ()) (Core.Resident_graphs_changed resident_ids) in + let owners = List.map (resident_scope configured.next) resident_ids in + let focused = List.fold_left (fun core id -> + let next = Core.step core (Core.Visible_graph_changed id) in + Alcotest.(check bool) "focus does not close/cancel graph operations" false + (List.exists (function Core.Delegate (Core.Detach_graph _) | Run (Core.Close_websocket _ | Cancel_effects _) -> true | _ -> false) next.effects); + Alcotest.(check bool) "requested graph becomes visible" true + ((Core.state next.next).snapshot.selected_graph = Some id); + List.iter2 (fun id owner -> Alcotest.(check bool) "original graph lifetime survives focus" true + (Core.scope_for_graph next.next ~graph_id:id = Some owner)) resident_ids owners; + next.next) configured.next (resident_ids @ [graph.graph_id]) in + check_residents resident_ids focused +let resident_removal_reopen_and_late_callback () = + let configured = Core.step (resident_catalog ()) (Core.Resident_graphs_changed resident_ids) in + let old = resident_scope configured.next other_graph.graph_id in + let removed = Core.step configured.next (Core.Resident_graphs_changed [graph.graph_id;third_graph.graph_id]) in + Alcotest.(check bool) "removal revokes only removed graph" true + (Core.scope_for_graph removed.next ~graph_id:other_graph.graph_id = None); + Alcotest.(check bool) "removal never deletes mirror/account" false + (List.exists (function Core.Delegate (Core.Delete_mirror _ | Reset_managed_account _) | Run (Core.Request (_, Core.Delete_account_secrets _)) -> true | _ -> false) removed.effects); + let reopened = Core.step removed.next (Core.Resident_graphs_changed resident_ids) in + let fresh = resident_scope reopened.next other_graph.graph_id in + Alcotest.(check bool) "reopened graph gets fresh lifetime" true (old <> fresh); + let late = Core.step reopened.next (Core.Graph_attached {scope=old;sync=Overlay.sync_view ~token:(Overlay.sync_token_of_string "sync-token:v1:1" |> Result.get_ok) ~checkpoint:(server_cursor_of_int 0) ~submissions:[]}) in + Alcotest.(check bool) "late old attachment cannot replace fresh scope" true + (Core.scope_for_graph late.next ~graph_id:other_graph.graph_id = Some fresh) +let resident_logout_clears_membership () = + let configured = Core.step (resident_catalog ()) (Core.Resident_graphs_changed resident_ids) in + let cleared = Core.step configured.next (Core.Account_authenticated {user_id=None}) in + check_residents [] cleared.next; + List.iter (fun id -> Alcotest.(check bool) "account retirement removes all scopes" true + (Core.scope_for_graph cleared.next ~graph_id:id = None)) resident_ids +let resident_catalog_codec_keeps_order () = + let configured = Core.step (resident_catalog ()) (Core.Resident_graphs_changed resident_ids) in + let focused = Core.step configured.next (Core.Visible_graph_changed other_graph.graph_id) in + let cache = List.find_map (function Core.Run (Core.Request (_, Core.Save_catalog {cache;_})) -> Some cache | _ -> None) focused.effects |> Option.get in + let decoded = Core.decode_catalog_cache (Core.encode_catalog_cache cache) |> Result.get_ok in + Alcotest.(check bool) "codec preserves resident order" true (Core.catalog_cache_resident_graphs decoded = resident_ids); + Alcotest.(check bool) "codec preserves visible graph" true (Core.catalog_cache_selected_graph decoded = Some other_graph.graph_id); + let old = Core.catalog_cache ~user_id:"user" ~graphs:[graph] ~selected_graph:(Some graph.graph_id) in + Alcotest.(check bool) "legacy single selection migrates" true (Core.catalog_cache_resident_graphs old = [graph.graph_id]) +let scenarios = scenarios @ + [ Alcotest.test_case "resident membership limits and order" `Quick resident_membership_limits_and_order + ; Alcotest.test_case "resident focus preserves lifetimes" `Quick resident_focus_preserves_lifetimes + ; Alcotest.test_case "resident remove reopen fences callbacks" `Quick resident_removal_reopen_and_late_callback + ; Alcotest.test_case "resident logout clears membership" `Quick resident_logout_clears_membership + ; Alcotest.test_case "resident catalog codec preserves order" `Quick resident_catalog_codec_keeps_order ] +;; + +let resident_restart_restores_membership_and_focus () = + let configured=Core.step (resident_catalog ()) (Core.Resident_graphs_changed (List.rev resident_ids)) in + let focused=Core.step configured.next (Core.Visible_graph_changed other_graph.graph_id) in + let cache=save_catalog_cache focused.effects |> Core.encode_catalog_cache |> Core.decode_catalog_cache |> Result.get_ok in + let restoring=Core.step (cold_start ()) (Core.Restore_local_account {user_id="user"}) in + let restored=Core.step restoring.next (load_catalog_completion restoring.effects (Some cache)) in + check_residents (List.rev resident_ids) restored.next; + Alcotest.(check bool) "restart restores selected resident" true + ((Core.state restored.next).snapshot.selected_graph=Some other_graph.graph_id); + List.iter (fun id -> Alcotest.(check bool) "restart creates each independent scope" true + (Option.is_some(Core.scope_for_graph restored.next ~graph_id:id))) resident_ids +;; +let resident_cache_rejects_invalid_membership () = + let configured=Core.step (resident_catalog ()) (Core.Resident_graphs_changed resident_ids) in + let cache=save_catalog_cache configured.effects |> Core.encode_catalog_cache |> Yojson.Safe.from_string in + let fields=Yojson.Safe.Util.to_assoc cache in + let uuid id=Logseq_db_types.Graph_types.Uuid.to_string id |> fun value -> `String value in + List.iter (fun ids -> + let source=`Assoc (("cache_resident_graphs",`List(List.map uuid ids))::List.remove_assoc "cache_resident_graphs" fields) + |> Yojson.Safe.to_string in + Alcotest.(check bool) "invalid durable membership is rejected" true + (Result.is_error(Core.decode_catalog_cache source))) + [resident_ids @ [fourth_graph.graph_id];[graph_id;graph_id];[third_graph.graph_id]] +;; +let resident_hidden_graphs_receive_shared_network_permission () = + let configured=Core.step (resident_catalog ()) (Core.Resident_graphs_changed [graph_id;other_graph_id]) in + let cache=save_catalog_cache configured.effects in + let restoring=Core.step (cold_start ()) (Core.Restore_local_account {user_id="user"}) in + let restored=Core.step restoring.next (load_catalog_completion restoring.effects (Some cache)) in + let attached=List.fold_left (fun core id -> + let scope=resident_scope core id in + let request=List.find_map (function Core.Delegate(Core.Inspect_mirror request) when request.scope=scope -> Some request|_->None) restored.effects |> Option.get in + let attaching=Core.step core (Core.Mirror_inspected(Core.Mirror_available request)) in + let token=Overlay.sync_token_of_string ("sync-token:v1:" ^ Logseq_db_types.Graph_types.Uuid.to_string id) |> Result.get_ok in + Core.step attaching.next (Core.Graph_attached {scope;sync=Overlay.sync_view ~token ~checkpoint:(server_cursor_of_int 0) ~submissions:[]}) + |> fun result->result.next) restored.next [graph_id;other_graph_id] in + let shown=Core.step attached (Core.Timeline_presented(timeline_ticket attached)) in + let online=Core.step shown.next (Core.Account_authenticated {user_id=Some "user"}) in + Alcotest.(check bool) "shared permission does not retire a connection still opening" false + (List.exists(function Core.Run(Core.Close_websocket _)->true|_->false) online.effects); + let sockets=List.filter_map (function Core.Run(Core.Start_websocket request)->Some request.scope.graph.graph_id|_->None) online.effects in + Alcotest.(check bool) "visible and hidden graph start independent sockets" true + (List.sort_uniq compare sockets=List.sort compare [graph_id;other_graph_id]); + List.iter (fun id -> let scope=resident_scope online.next id in + let state=Core.state_for_scope online.next scope |> Option.get in + Alcotest.(check bool) "network permission shared with hidden graph" true + (state.snapshot.startup.network_phase<>Core.Local_only)) [graph_id;other_graph_id] +;; +let scenarios=scenarios @ + [Alcotest.test_case "resident restart restores order and focus" `Quick resident_restart_restores_membership_and_focus + ;Alcotest.test_case "resident durable membership validation" `Quick resident_cache_rejects_invalid_membership + ;Alcotest.test_case "resident hidden network permission" `Quick resident_hidden_graphs_receive_shared_network_permission] +;; + +let resident_online_fixture () = + let configured=Core.step (resident_catalog ()) (Core.Resident_graphs_changed [graph_id;other_graph_id]) in + let cache=save_catalog_cache configured.effects in + let restoring=Core.step (cold_start ()) (Core.Restore_local_account {user_id="user"}) in + let restored=Core.step restoring.next (load_catalog_completion restoring.effects (Some cache)) in + let attached=List.fold_left (fun core id -> + let scope=resident_scope core id in + let request=List.find_map (function Core.Delegate(Core.Inspect_mirror request) when request.scope=scope -> Some request|_->None) restored.effects |> Option.get in + let attaching=Core.step core (Core.Mirror_inspected(Core.Mirror_available request)) in + let token=Overlay.sync_token_of_string ("sync-token:v1:" ^ Logseq_db_types.Graph_types.Uuid.to_string id) |> Result.get_ok in + Core.step attaching.next (Core.Graph_attached {scope;sync=Overlay.sync_view ~token ~checkpoint:(server_cursor_of_int 0) ~submissions:[]}) + |> fun result->result.next) restored.next [graph_id;other_graph_id] in + let shown=Core.step attached (Core.Timeline_presented(timeline_ticket attached)) in + Core.step shown.next (Core.Account_authenticated {user_id=Some "user"}) + +;; +let resident_online_cancel_retires_and_resumes_hidden () = + let online=resident_online_fixture () in + let scopes=List.filter_map(function Core.Run(Core.Start_websocket request)->Some request.scope|_->None) online.effects in + let opened=List.fold_left(fun core scope->(Core.step core (Core.Websocket_opened scope)).next) online.next scopes in + let cancelled=Core.step opened (Core.Online_cancelled(startup_ticket opened)) in + let closes=List.filter_map(function Core.Run(Core.Close_websocket scope)->Some scope|_->None) cancelled.effects in + Alcotest.(check int) "all graph policies enter physical retirement" 2 (List.length(List.sort_uniq compare closes)); + List.iter(fun early-> + let authenticated core=let sign_in=Core.step core (Core.Sign_in_requested(startup_ticket core)) in + Core.step sign_in.next (Core.Account_authenticated {user_id=Some "user"}) in + let ready,initial_effects=if early then let auth=authenticated cancelled.next in auth.next,auth.effects + else cancelled.next,[] in + let retired=List.fold_left(fun result scope-> + let part=Core.step result.Core.next (Core.Websocket_retired scope) in + {Core.next=part.next;effects=result.effects @ part.effects}) {Core.next=ready;effects=initial_effects} scopes in + let resumed=if early then retired else let auth=authenticated retired.next in {auth with effects=retired.effects @ auth.effects} in + let starts=List.filter_map(function Core.Run(Core.Start_websocket request)->Some request.scope.graph.graph_id|_->None) resumed.effects in + Alcotest.(check bool) "hidden graph resumes both before and after terminal retirement" true + (List.sort_uniq compare starts=List.sort compare [graph_id;other_graph_id])) [false;true] +;; +let scenarios=scenarios @ [Alcotest.test_case "resident online cancellation resumes every graph" `Quick resident_online_cancel_retires_and_resumes_hidden] +;; + +(* PR142 recovery must preserve resident lifetime and account presentation rules. *) +let resident_recovery_allocates_above_all_live_generations () = + let configured=Core.step (resident_catalog ()) (Core.Resident_graphs_changed resident_ids) in + let owners=List.map(resident_scope configured.next) resident_ids in + let a=List.hd owners in + let failed=Core.step configured.next (Core.Graph_attachment_failed {scope=Core.effect_scope_of_graph a;message="Synthetic local open failure"}) in + let recovered=Core.step failed.next (Core.Online_recovery_requested(timeline_scope failed.next)) in + let fresh=inspect_mirror_request recovered.effects in + Alcotest.(check bool) "recovery allocates above every resident, not the failing session" true + (List.for_all(fun (owner:Core.graph_scope)->fresh.scope.graph_generation>owner.graph_generation) owners); + Alcotest.(check bool) "fresh recovery scope remains admitted" true + (Core.scope_for_graph recovered.next ~graph_id=Some fresh.scope); + List.iter(fun (owner:Core.graph_scope)->if owner.graph_id<>graph_id then + Alcotest.(check bool) "other lifetimes unchanged" true + (Core.scope_for_graph recovered.next ~graph_id:owner.graph_id=Some owner)) owners; + let stale=Core.step recovered.next (Core.Mirror_inspected(Core.Mirror_available {fresh with scope=a})) in + Alcotest.(check bool) "old recovery callback cannot attach" false + (List.exists(function Core.Delegate(Core.Attach_graph _)->true|_->false) stale.effects); + let attached=Core.step stale.next (Core.Mirror_inspected(Core.Mirror_available fresh)) in + Alcotest.(check bool) "new recovery callback keeps its graph" true + (List.exists(function Core.Delegate(Core.Attach_graph r)->r.scope=fresh.scope|_->false) attached.effects) +;; +let resident_focus_keeps_native_presentation_monotonic () = + let configured=Core.step (resident_catalog ()) (Core.Resident_graphs_changed resident_ids) in + let owners=List.map(resident_scope configured.next) resident_ids in + let newer=Core.step configured.next (Core.Visible_graph_changed graph_id) in + let native=startup_scope newer.next in + let focused=Core.step newer.next (Core.Visible_graph_changed other_graph_id) in + let visible=startup_scope focused.next in + Alcotest.(check bool) "native scope never regresses on focus" true + (visible.presentation_generation>=native.presentation_generation); + List.iter2(fun id scope->Alcotest.(check bool) "presentation refresh preserves graph lifetime" true + (Core.scope_for_graph focused.next ~graph_id:id=Some scope)) resident_ids owners; + let cancel=Core.step focused.next (Core.Online_cancelled visible) in + Alcotest.(check bool) "native current-scope cancellation reaches focused graph" true + ((Core.state cancel.next).snapshot.startup.network_phase=Core.Local_only); + let stale=Core.step cancel.next (Core.Sign_in_requested visible) in + Alcotest.(check bool) "cancel retires old presentation tickets" true + ((Core.state stale.next).snapshot.startup.network_phase=Core.Local_only); + let resumed=Core.step cancel.next (Core.Sign_in_requested(startup_scope cancel.next)) in + Alcotest.(check bool) "fresh native authentication can resume" true + ((Core.state resumed.next).snapshot.startup.network_phase=Core.Online_by_user) +;; +let resident_local_retry_restarts_only_failed_graph () = + let configured=Core.step (resident_catalog ()) (Core.Resident_graphs_changed resident_ids) in + let old=resident_scope configured.next graph_id in + let failed=Core.step configured.next (Core.Graph_attachment_failed {scope=Core.effect_scope_of_graph old;message="Synthetic read failure"}) in + let retry=Core.step failed.next (Core.Graph_selected graph_id) in + let request=inspect_mirror_request retry.effects in + Alcotest.(check bool) "retry admits its new local-open scope" true + (Core.scope_for_graph retry.next ~graph_id=Some request.scope); + Alcotest.(check bool) "retry renews only failed lifetime" true (request.scope<>old); + Alcotest.(check bool) "retry retains configured order" true (Core.resident_graphs retry.next=resident_ids); + let inspected=Core.step retry.next (Core.Mirror_inspected(Core.Mirror_available request)) in + Alcotest.(check bool) "retried mirror advances to attachment" true + (List.exists(function Core.Delegate(Core.Attach_graph r)->r.scope=request.scope|_->false) inspected.effects) +;; +let scenarios=scenarios @ [ + Alcotest.test_case "resident recovery uses global lifetime allocator" `Quick resident_recovery_allocates_above_all_live_generations; + Alcotest.test_case "resident focus preserves native presentation scope" `Quick resident_focus_keeps_native_presentation_monotonic; + Alcotest.test_case "resident local retry keeps renewed scope admitted" `Quick resident_local_retry_restarts_only_failed_graph] +;; + +let resident_authentication_recovery_renews_scope () = + let configured=Core.step (resident_catalog ()) (Core.Resident_graphs_changed resident_ids) in + let old=resident_scope configured.next graph_id in + let cancelled=Core.step configured.next (Core.Online_cancelled(startup_scope configured.next)) in + let failed=Core.step cancelled.next (Core.Graph_attachment_failed{scope=Core.effect_scope_of_graph old;message="Synthetic local restore failure"}) in + let online=Core.step failed.next (Core.Sign_in_requested(startup_scope failed.next)) in + let recovered=Core.step online.next (Core.Account_authenticated{user_id=Some "user"}) in + let request=inspect_mirror_request recovered.effects in + Alcotest.(check bool) "authentication recovery registers fresh graph lifetime" true + (Core.scope_for_graph recovered.next ~graph_id=Some request.scope); + List.iter(fun id->let owner=resident_scope configured.next id in + Alcotest.(check bool) "authentication recovery generation unique" true + (request.scope.graph_generation>owner.graph_generation)) resident_ids +;; +let scenarios=scenarios @ [Alcotest.test_case "resident authentication recovery uses global lifetime" `Quick resident_authentication_recovery_renews_scope] +;; + +let resident_recovery_keeps_other_graph_completion () = + let catalog=refresh_catalog (resident_catalog ()) [graph;{other_graph with encrypted=true};third_graph] in + let configured=Core.step catalog.next (Core.Resident_graphs_changed resident_ids) in + let b=resident_scope configured.next other_graph_id in + let b_request=List.find_map(function Core.Delegate(Core.Inspect_mirror r) when r.scope=b->Some r|_->None) configured.effects |> Option.get in + let unlocking=Core.step configured.next (Core.Mirror_inspected(Core.Mirror_available b_request)) in + let fail_b=List.find_map(function Core.Run(Core.Request(ticket,Core.Load_and_unlock_graph_key _))-> + Some(Core.Runner_completed(Core.Completion(ticket,Ok(Core.graph_key_handle ~id:"synthetic-B-key" ~scope:b))))|_->None) unlocking.effects |> Option.get in + let a=resident_scope unlocking.next graph_id in + let failed=Core.step unlocking.next (Core.Graph_attachment_failed{scope=Core.effect_scope_of_graph a;message="Synthetic A open failure"}) in + let recovered=Core.step failed.next (Core.Online_recovery_requested(timeline_scope failed.next)) in + let fresh=inspect_mirror_request recovered.effects in + let completed=Core.step recovered.next fail_b in + Alcotest.(check bool) "B completion advances only B attachment after A recovery" true + (List.exists(function Core.Delegate(Core.Attach_graph request)->request.scope=b|_->false) completed.effects); + Alcotest.(check bool) "B completion keeps A recovery pending" true + ((Core.state completed.next).snapshot.startup.restoring_local); + Alcotest.(check bool) "A scope still admits its own mirror response" true + (Core.scope_for_graph completed.next ~graph_id=Some fresh.scope) +;; +let scenarios=scenarios @ [Alcotest.test_case "resident recovery preserves other key completion" `Quick resident_recovery_keeps_other_graph_completion] +;; + +let resident_reopened_lifetime_finishes_owned_retirement () = + let online=resident_online_fixture () in + let sockets=List.filter_map(function Core.Run(Core.Start_websocket r)->Some r.scope|_->None) online.effects in + let opened=List.fold_left(fun c socket->(Core.step c (Core.Websocket_opened socket)).next) online.next sockets in + let old=resident_scope opened graph_id in + let pending,ticket,_=asset_issue opened old "old-resident-lease" (Core.Retain_asset_file "source") in + let replaced=Core.step pending.next (Core.Graph_selected graph_id) in + let mirror=inspect_mirror_request replaced.effects in + let stale=Core.step replaced.next (Core.Local_graph_outbox_changed old) in + check_instructions "retired outbox cannot request work in new lifetime" [] stale.effects; + let late=asset_complete stale.next ticket (Ok(Core.Asset_retained(Some("late-resident-lease","synthetic-path")))) in + Alcotest.(check int) "retired success never publishes the resource" 0 (List.length(asset_outputs late.effects)); + let cleanup=List.find_map(function Core.Run(Core.Asset_io(ticket,request)) when request.action=Core.Release_asset_file "late-resident-lease"->Some ticket|_->None) late.effects in + Alcotest.(check bool) "new lifetime retains exact old asset cleanup owner" true (Option.is_some cleanup); + let released=asset_complete late.next (Option.get cleanup) (Ok Core.Asset_unit) in + let duplicate=asset_complete released.next ticket (Ok(Core.Asset_retained(Some("late-resident-lease","synthetic-path")))) in + check_instructions "retired asset completion is consumed once" [] duplicate.effects; + let attaching=Core.step duplicate.next (Core.Mirror_inspected(Core.Mirror_available mirror)) in + let module O=Logseq_overlay_db.Types in + let sync=O.sync_view ~token:(O.sync_token_of_string "sync-token:v1:reopened" |> Result.get_ok) + ~checkpoint:(server_cursor_of_int 0) ~submissions:[] in + let attached=Core.step attaching.next (Core.Graph_attached{scope=mirror.scope;sync}) in + let shown=Core.step attached.next (Core.Timeline_presented(timeline_scope attached.next)) in + let authenticated=Core.step shown.next (Core.Account_authenticated{user_id=Some "user"}) in + let socket=List.find(fun(s:Core.connection_scope)->s.graph=old) sockets in + let retired=Core.step authenticated.next (Core.Websocket_retired socket) in + Alcotest.(check int) "old physical retirement starts new socket once" 1 + (List.length(List.filter(function Core.Run(Core.Start_websocket r)->r.scope.graph=mirror.scope|_->false) retired.effects)); + check_instructions "duplicate old physical retirement is inert" [] (Core.step retired.next (Core.Websocket_retired socket)).effects +;; +let scenarios=scenarios @ [Alcotest.test_case "resident refreshed lifetime drains exact old owners" `Quick resident_reopened_lifetime_finishes_owned_retirement] +;; + +let resident_cancel_publishes_final_presentation () = + List.iter(fun count->let ids=List.filteri(fun i _->i + let configured=Core.step (resident_catalog ()) (Core.Resident_graphs_changed ids) in + let focused=Core.step configured.next (Core.Visible_graph_changed visible) in + let cancelled=Core.step focused.next (Core.Online_cancelled(startup_scope focused.next)) in + let published=List.fold_left(fun state->function Core.Publish(Core.State_changed value)->Some value|_->state) None cancelled.effects |> Option.get in + Alcotest.(check bool) "native receives final account presentation after every cancellation order" true + (published.snapshot.startup=(Core.state cancelled.next).snapshot.startup)) ids) [1;2;3] +;; +let resident_retry_retains_new_catalog_completion () = + let configured=Core.step (resident_catalog ()) (Core.Resident_graphs_changed resident_ids) in + let retry=Core.step configured.next (Core.Graph_selected graph_id) in + let error=Core.Effect_failed "Synthetic resident catalog persistence failure" in + let completion=List.find_map(function Core.Run(Core.Request(ticket,Core.Save_catalog _))-> + Some(Core.Runner_completed(Core.Completion(ticket,Error error)))|_->None) retry.effects |> Option.get in + let failed=Core.step retry.next completion in + Alcotest.(check bool) "new retry catalog ticket preserves persistence failure diagnostic" true + ((Core.state failed.next).diagnostics<>(Core.state retry.next).diagnostics); + check_instructions "catalog persistence completion consumed once" [] (Core.step failed.next completion).effects +;; +let scenarios=scenarios @ [ + Alcotest.test_case "resident cancellation publishes final native scope" `Quick resident_cancel_publishes_final_presentation; + Alcotest.test_case "resident retry preserves catalog persistence ticket" `Quick resident_retry_retains_new_catalog_completion] +;; + +let resident_graph_failure_preserves_account_catalog_request () = + let configured=Core.step (resident_catalog ()) (Core.Resident_graphs_changed resident_ids) in + let requested=Core.step configured.next (Core.Account_authenticated{user_id=Some "user"}) in + let updated_graph={graph with name="Authoritative catalog update"} in + let completion=List.find_map(function Core.Run(Core.Request(ticket,Core.Fetch_catalog _))-> + Some(Core.Runner_completed(Core.Completion(ticket,Ok[updated_graph;other_graph;third_graph;fourth_graph])))|_->None) requested.effects |> Option.get in + let hidden=resident_scope requested.next other_graph_id in + let failed=Core.step requested.next (Core.Graph_attachment_failed{scope=Core.effect_scope_of_graph hidden;message="Synthetic hidden graph failure"}) in + let completed=Core.step failed.next completion in + Alcotest.(check bool) "hidden graph failure cannot discard shared account catalog completion" true + (List.exists(fun(g:Core.graph)->g.graph_id=graph_id && g.name=updated_graph.name)(Core.state completed.next).snapshot.catalog); + Alcotest.(check bool) "shared catalog completion releases loading" false + (Core.state completed.next).snapshot.startup.catalog_loading; + check_instructions "catalog request completion consumed once" [] (Core.step completed.next completion).effects +;; +let scenarios=scenarios @ [Alcotest.test_case "resident graph failure preserves account catalog ticket" `Quick resident_graph_failure_preserves_account_catalog_request] +;; diff --git a/logseq_sync/test/runner_contract.ml b/logseq_sync/test/runner_contract.ml index a930acab..8589866f 100644 --- a/logseq_sync/test/runner_contract.ml +++ b/logseq_sync/test/runner_contract.ml @@ -2455,3 +2455,45 @@ let test_asset_authentication_cause_is_scoped () = let scenarios = scenarios @ [Alcotest.test_case "asset authentication cause is scoped" `Quick test_asset_authentication_cause_is_scoped] ;; + +(* Physical socket admission is owned by Runner, not the pure instruction producer. *) +let test_resident_websocket_owners_are_independent () = + with_support(fun support->Eio_main.run(fun environment->Eio.Switch.run(fun sw-> + let ids=[Core_contract.graph_id;Core_contract.other_graph_id] in + let configured=Core.step (Core_contract.resident_catalog ()) (Core.Resident_graphs_changed ids) in + let cache=Core_contract.save_catalog_cache configured.effects in + let restoring=Core.step (Core_contract.cold_start ()) (Core.Restore_local_account {user_id="user"}) in + let restored=Core.step restoring.next (Core_contract.load_catalog_completion restoring.effects (Some cache)) in + let attached=List.fold_left(fun current id-> + let scope=Core.scope_for_graph current ~graph_id:id |> Option.get in + let request=List.find_map(function Core.Delegate(Core.Inspect_mirror request) when request.scope=scope -> Some request|_->None) restored.effects |> Option.get in + let attaching=Core.step current (Core.Mirror_inspected(Core.Mirror_available request)) in + let token=Logseq_overlay_db.Types.sync_token_of_string "sync-token:v1:resident-runner" |> Result.get_ok in + let sync=Logseq_overlay_db.Types.sync_view ~token ~checkpoint:(Core_contract.server_cursor_of_int 0) ~submissions:[] in + (Core.step attaching.next (Core.Graph_attached {scope;sync})).next) restored.next ids in + let shown=Core.step attached (Core.Timeline_presented(Core_contract.timeline_ticket attached)) in + let online=Core.step shown.next (Core.Account_authenticated {user_id=Some "user"}) in + let current=ref online.next in + let starts=List.filter_map(function Core.Run(Core.Start_websocket _ as instruction)->Some instruction|_->None) online.effects in + Alcotest.(check int) "reducer requests both physical connections" 2 (List.length starts); + let queued=Queue.create () and posted=ref [] and acquired=ref 0 in + let token=Runner.id_token_provider ~acquire:(fun _->incr acquired;Error "Synthetic blocked network") ~invalidate:(fun _ ~token:_->()) in + let deps=dependencies ~environment ~support ~fork:(fun ~sw:_ callback->Queue.add callback queued) ~id_token_dependency:token () in + let runner=Runner.create ~sw deps ~post:(fun event->posted:=event::!posted) |> Result.get_ok in + List.iter(Runner.submit runner) starts; + while not(Queue.is_empty queued) do (Queue.take queued) () done; + Alcotest.(check int) "both graph owners reach independent token/connect path" 2 !acquired; + Alcotest.(check bool) "no graph blocks another physical owner" false + (List.exists(function Core.Websocket_closed(_,Core.Protocol_failure _)->true|_->false) !posted); + List.iter(fun instruction->match instruction with + | Core.Start_websocket request -> + let transition=Core.step !current (Core.Websocket_closed(request.scope,Core.Authentication_failure "Synthetic blocked network")) in + current:=transition.next;List.iter(function Core.Run runnable->Runner.submit runner runnable|_->()) transition.effects + | _->()) starts; + while not(Queue.is_empty queued) do (Queue.take queued) () done; + Alcotest.(check int) "both graph owners retire separately" 2 + (List.length(List.filter(function Core.Websocket_retired _->true|_->false) !posted)); + Runner.shutdown runner))) +;; +let scenarios=scenarios @ [Alcotest.test_case "resident physical sockets admit and retire independently" `Quick test_resident_websocket_owners_are_independent] +;; diff --git a/logseq_sync/test/transport_contract.ml b/logseq_sync/test/transport_contract.ml index 89d9ed00..d0ec8b09 100644 --- a/logseq_sync/test/transport_contract.ml +++ b/logseq_sync/test/transport_contract.ml @@ -4536,3 +4536,202 @@ let scenarios = test_upgrade_rejection_reason ] ;; + +(* A real transport table can collide even when pure Core emits correctly scoped + instructions. This peer exercises simultaneous TLS channels and their traffic; + it does not stand in for an authoritative DB transaction or GUI acceptance. *) +module Resident_network_fixture = struct +module C = Core +module R = Runner +module O = Logseq_overlay_db.Types +module U = Logseq_db_types.Graph_types.Uuid +let peer = {resident_peer| +import base64, hashlib, json, os, pathlib, socket, ssl, struct, select, subprocess, sys, threading, time +root = pathlib.Path(sys.argv[1]) +key, cert = root/'ephemeral-key.pem', root/'ephemeral-cert.pem' +subprocess.run(['openssl','req','-x509','-newkey','ec','-pkeyopt','ec_paramgen_curve:P-256', + '-nodes','-keyout',str(key),'-out',str(cert),'-days','1','-subj','/CN=localhost', + '-addext','subjectAltName=DNS:localhost'],check=True,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL) +context=ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER);context.load_cert_chain(cert,key) +listener=socket.socket(socket.AF_INET6);listener.setsockopt(socket.IPPROTO_IPV6,socket.IPV6_V6ONLY,1);listener.bind(('::1',0));listener.listen(2) +listener4=socket.socket();listener4.bind(('127.0.0.1',listener.getsockname()[1]));listener4.listen(2) +(root/'ready').write_text(str(listener.getsockname()[1])) +lock=threading.Lock();barrier=threading.Barrier(2);events=[];active=set();peak=0;threads=[] +def note(kind,graph,**values): + with lock: + events.append(dict(kind=kind,graph=graph,**values)) + (root/'peer-progress.json').write_text(json.dumps({'peak_simultaneous_sockets':peak,'events':events},indent=2)+'\n') +def send(conn,value): + data=json.dumps(value,separators=(',',':')).encode();length=len(data) + conn.sendall(bytes([0x81,length]) + data if length<126 else bytes([0x81,126])+struct.pack('!H',length)+data) +def receive(conn): + def exact(n): + data=b'' + while len(data)()) +let require label condition=if not condition then failwith label +let cursor n=Logseq_db_types.Server_cursor.of_int64 (Int64.of_int n) |> Result.get_ok +let graphs : C.graph list=List.map(fun(id,name)->({graph_id=U.of_string id |> Result.get_ok;name; + schema={major=65;minor=33;exact=true};encrypted=false}:C.graph)) + ["11111111-1111-4111-8111-111111111111","Synthetic A";"99999999-9999-4999-8999-999999999999","Synthetic B"] +let startup core : C.startup_scope=let f=(C.state core).snapshot.startup in + {account_generation=f.account_generation;presentation_generation=f.presentation_generation} +let run ()=with_support(fun root-> + let path file=Filename.concat root file in + Eio_main.run(fun env->Eio.Switch.run(fun sw-> + let clock=Eio.Stdenv.clock env in + let wait label predicate=Eio.Time.with_timeout_exn clock 10. (fun()-> + while not(predicate()) do Eio.Time.sleep clock 0.01 done);Printf.printf "PASS %s\n%!" label in + let process=Eio.Process.spawn ~sw (Eio.Stdenv.process_mgr env) ["python3";"-c";peer;root] in + wait "loopback TLS peer ready" (fun()->Sys.file_exists(path "ready")); + let port=int_of_string(read(path "ready")) in + let origin=Uri.of_string(Printf.sprintf "https://localhost:%d" port) in + let limits=C.limits ~maximum_response_bytes:Logseq_db_types.Limits.maximum_response_bytes ~maximum_artifact_bytes:1048576 ~submission_batch_size:32 |> Result.get_ok in + let initial=C.config ~managed_sync_origin:origin ~limits |> Result.get_ok |> C.initial |> Result.get_ok in + let signed=C.step initial (C.Sign_in_requested(startup initial)) in + let auth=C.step signed.next (C.Account_authenticated {user_id=Some "synthetic-local-user"}) in + let catalog=List.find_map(function C.Run(C.Request(ticket,C.Fetch_catalog _))-> + Some(C.step auth.next (C.Runner_completed(C.Completion(ticket,Ok graphs))))|_->None) auth.effects |> Option.get in + let selected=C.step catalog.next (C.Graph_selected(List.hd graphs).graph_id) in + let configured=C.step selected.next (C.Resident_graphs_changed(List.map(fun(g:C.graph)->g.graph_id) graphs)) in + let inspections=selected.effects @ configured.effects in + let current,starts=List.fold_left(fun(core,starts)(graph:C.graph)-> + let scope=C.scope_for_graph core ~graph_id:graph.graph_id |> Option.get in + let request=List.find_map(function C.Delegate(C.Inspect_mirror request) when request.scope=scope ->Some request|_->None) inspections |> Option.get in + let inspected=C.step core (C.Mirror_inspected(C.Mirror_available request)) in + let token=O.sync_token_of_string("sync-token:v1:" ^ U.to_string graph.graph_id) |> Result.get_ok in + let sync=O.sync_view ~token ~checkpoint:(cursor 0) ~submissions:[] in + let attached=C.step inspected.next (C.Graph_attached {scope;sync}) in + attached.next,starts @ List.filter_map(function C.Run(C.Start_websocket _ as runnable)->Some runnable|_->None) attached.effects) + (configured.next,[]) graphs in + require "two real graph connect instructions" (List.length starts=2); + let core=ref current and events=Eio.Stream.create 128 and opened=ref [] and messages=ref [] in + let cert=X509.Certificate.decode_pem(read(path "ephemeral-cert.pem")) |> Result.get_ok in + let fingerprint=X509.Certificate.fingerprint `SHA256 cert in + let authenticator=X509.Authenticator.cert_fingerprint ~time:(fun()->Some(Ptime_clock.now())) ~hash:`SHA256 ~fingerprint in + let secrets=R.secrets ~unlock_private_key:(fun ~managed_sync_origin:_ ~user_id:_ ~password:_ ~private_key_package:_->Error "Unused") + ~unlock_graph_key:(fun ~managed_sync_origin:_ ~user_id:_ ~encrypted_graph_key:_->Error "Unused") + ~load_wrapped_graph_key:(fun ~managed_sync_origin:_ ~user_id:_ ~graph_id:_->Error(R.Wrapped_graph_key_unavailable "Unused")) + ~verify_and_save_wrapped_graph_key:(fun ~managed_sync_origin:_ ~user_id:_ ~graph_id:_ ~encrypted_graph_key:_->Error "Unused") + ~delete_account_secrets:(fun ~managed_sync_origin:_ ~user_id:_->Ok()) |> Result.get_ok in + let crypto=R.crypto ~encrypt_aes_gcm:(fun ~key:_ ~plaintext:_->Error "Unused") + ~decrypt_aes_gcm:(fun ~key:_ ~iv:_ ~ciphertext:_->Error "Unused") |> Result.get_ok in + let deps=R.dependencies ~runtime:(R.runtime ~fork:Eio.Fiber.fork ~sleep:(Eio.Time.sleep clock) |> Result.get_ok) + ~transport:(R.transport ~websocket_liveness:R.Disabled ~tls_authenticator:(R.tls_authenticator authenticator) + ~network:(Eio.Stdenv.net env) ~clock |> Result.get_ok) + ~local_store:(R.local_store ~application_support_directory:root () |> Result.get_ok) + ~artifact_store:(R.artifact_store ~staging_directory:(path "staging") |> Result.get_ok) + ~secrets ~crypto ~id_token_provider:(R.id_token_provider ~acquire:(fun account-> + require "synthetic token stays on loopback" (account.C.managed_sync_origin=origin);Ok "synthetic-local-fixture") + ~invalidate:(fun _ ~token:_->())) |> Result.get_ok in + let runner=R.create ~sw deps ~post:(fun event-> + (match event with C.Websocket_closed(_,C.Recoverable_close{message;_})->Printf.eprintf "CLOSED %s\n%!" (Option.value message ~default:"none") + | Websocket_closed(_,(Authentication_failure message|Protocol_failure message))->Printf.eprintf "CLOSED %s\n%!" message + | _->());Eio.Stream.add events event) |> Result.get_ok in + let run=function + | C.Start_websocket _ | Send_websocket _ | Close_websocket _ | Cancel_effects _ | Cancel_network_effects _ | Schedule_timer _ as op->R.submit runner op + | Request _ | Asset_io _ | Protected_io _ -> () in + let running=ref true in + Eio.Fiber.fork ~sw (fun()->while !running do + let event=Eio.Stream.take events in + (match event with C.Websocket_opened scope->opened:=scope::!opened + | Websocket_message(scope,message)->messages:=(scope,message)::!messages|_->()); + let next=C.step !core event in core:=next.next; + List.iter(function C.Run instruction->run instruction|_->()) next.effects + done); + List.iter run starts; + wait "two concurrent authenticated TLS WebSockets" (fun()->List.length !opened=2); + wait "baseline pull responses received for both graphs" (fun()->List.filter_map(function (scope:C.connection_scope),Logseq_sync_pure_reducer.Sync_protocol.Server.Pull_ok _->Some scope.graph.graph_id|_->None) !messages |> List.sort_uniq compare |> List.length = 2); + let a=(List.nth graphs 0).graph_id and b=(List.nth graphs 1).graph_id in + require "A remains visible before remote injection" ((C.state !core).snapshot.selected_graph=Some a); + touch(path "inject"); + wait "hidden B receives real server Changed at cursor 1" (fun()->List.exists(function (scope:C.connection_scope),Logseq_sync_pure_reducer.Sync_protocol.Server.Changed {t}->scope.C.graph.graph_id=b && t=cursor 1|_->false) !messages); + require "remote B event does not focus B" ((C.state !core).snapshot.selected_graph=Some a); + Eio.Time.sleep clock 0.05; + let removal=C.step !core (C.Resident_graphs_changed [b]) in core:=removal.next; + List.iter(function C.Run instruction->run instruction|_->()) removal.effects; + wait "closing A retires only its real socket" (fun()->Sys.file_exists(path "retired-A")); + let b_responses ()=List.length(List.filter(function (scope:C.connection_scope),Logseq_sync_pure_reducer.Sync_protocol.Server.Pull_ok _->scope.graph.graph_id=b|_->false) !messages) in + let before=b_responses () in touch(path "after-A"); + wait "B completes a real retry pull/response after A retires" (fun()-> + b_responses ()>before && (try + let progress=path "peer-progress.json" in + let open Yojson.Safe.Util in Yojson.Safe.from_file progress |> member "events" |> to_list + |> List.exists(fun event->member "kind" event=`String "reply-sent" && member "graph" event=`String "B" + && member "phase" event=`String "after-A") with _->false)); + require "B remains open after successful independent roundtrip" (not(Sys.file_exists(path "retired-B"))); + R.shutdown runner;touch(path "stop");running:=false;Eio.Stream.add events C.Shutdown; + Eio.Time.with_timeout_exn clock 10. (fun()->Eio.Process.await_exn process); + let report=Yojson.Safe.from_file(path "peer-report.json") in + let open Yojson.Safe.Util in + require "server observed real overlap" (member "peak_simultaneous_sockets" report |> to_int=2); + require "hidden B sent its own pull after notification" (member "events" report |> to_list |> List.exists(fun event-> + member "graph" event=`String "B" && member "phase" event=`String "after-injection" + && (event |> member "message" |> member "type") = `String "pull")); + print_endline "PASS hidden graph real TLS notification, scoped pull, and independent retirement"; + print_endline "LIMIT transport/core acceptance only; no DB transaction or GUI rendering claimed"))) +;; +end +let scenarios=scenarios @ [Alcotest.test_case + "resident real TLS channels keep hidden pull and retirement independent" + `Quick Resident_network_fixture.run] +;; diff --git a/swift/JournalChrome.swift b/swift/JournalChrome.swift index c499cc10..8be42103 100644 --- a/swift/JournalChrome.swift +++ b/swift/JournalChrome.swift @@ -25,6 +25,7 @@ struct JournalSheetDismissButton: SwiftUI.View { @MainActor enum JournalChrome { private enum Mode: String, Decodable { case page, header, detail, unlock, startup + case graphPages = "graph-pages" case graphPicker = "graph-picker" case toolbarControl = "toolbar-control" case detailText = "detail-text" @@ -48,9 +49,133 @@ struct JournalSheetDismissButton: SwiftUI.View { let active: Bool? let value: Double? let message: String? + let enabled: Bool? + let pageID: String? + let targetID: String? + let targetPageID: String? + let residentPageIDs: [String]? } /// Syntax and reference resolution belong to OCaml; this maps presentation data. + #if os(iOS) + /// Native direction admission fails before vertical List scrolling is claimed. + private struct RightGraphPan: UIGestureRecognizerRepresentable { + let enabled: Bool + let changed: (UIPanGestureRecognizer) -> Void + final class Coordinator: NSObject, UIGestureRecognizerDelegate { + var enabled = false + func gestureRecognizerShouldBegin(_ recognizer: UIGestureRecognizer) -> Bool { + guard enabled, let pan = recognizer as? UIPanGestureRecognizer else { return false } + let velocity = pan.velocity(in: pan.view) + return velocity.x > 0 && velocity.x > abs(velocity.y) * 1.4 + } + func gestureRecognizer(_ recognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { + var view = touch.view + while let current = view { + if current is UIControl || current is UITextView || current is UITextField { return false } + view = current.superview + } + return enabled + } + } + func makeCoordinator(converter: CoordinateSpaceConverter) -> Coordinator { Coordinator() } + func makeUIGestureRecognizer(context: Context) -> UIPanGestureRecognizer { + let pan = UIPanGestureRecognizer() + pan.maximumNumberOfTouches = 1 + pan.delegate = context.coordinator + context.coordinator.enabled = enabled + return pan + } + func updateUIGestureRecognizer(_ recognizer: UIPanGestureRecognizer, context: Context) { + context.coordinator.enabled = enabled + // Do not cancel a gesture merely because its preview is being populated. + if recognizer.state == .possible { recognizer.isEnabled = enabled } + } + func handleUIGestureRecognizerAction(_ recognizer: UIPanGestureRecognizer, context: Context) { + changed(recognizer) + } + } + private struct GraphPages: SwiftUI.View { + let context: LUIAppleExtensionViewContext + let properties: Properties + let current: AnyView + let preview: AnyView + @State private var translation: CGFloat = 0 + @State private var dragging = false + @State private var settling = false + @Environment(\.journalGraphScrollOffsets) private var graphScrollOffsets + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + private func emit(_ action: String) { + JournalExtensions.emit(context: context, payload: Data(action.utf8)) + } + private func handle(_ pan: UIPanGestureRecognizer, width: CGFloat) { + switch pan.state { + case .began: + guard properties.enabled == true, !settling else { return } + dragging = true + emit("prepare") + translation = min(width, max(0, pan.translation(in: pan.view).x)) + case .changed where dragging: + translation = min(width, max(0, pan.translation(in: pan.view).x)) + case .ended where dragging: + dragging = false + let distance = pan.translation(in: pan.view).x + let velocity = pan.velocity(in: pan.view).x + let commit = distance > width * 0.3 || (distance > 30 && velocity > 650) + settling = true + withAnimation(reduceMotion ? .linear(duration: 0.01) : .smooth(duration: 0.24), completionCriteria: .logicallyComplete) { + translation = commit ? width : 0 + } completion: { + emit(commit ? "commit" : "cancel") + if !commit { settling = false } + } + case .cancelled, .failed: + guard dragging else { return } + dragging = false + withAnimation(.smooth(duration: 0.2)) { translation = 0 } + emit("cancel") + default: break + } + } + var body: some SwiftUI.View { + GeometryReader { bounds in + ZStack(alignment: .topLeading) { + preview + .environment(\.journalGraphScrollScope, properties.targetPageID.map { JournalGraphScrollScope(pageID: $0, records: false) }) + .frame(width: bounds.size.width, height: bounds.size.height) + .offset(x: translation - bounds.size.width) + .allowsHitTesting(false) + .accessibilityHidden(true) + current + .environment(\.journalGraphScrollScope, properties.pageID.map { JournalGraphScrollScope(pageID: $0, records: true) }) + .frame(width: bounds.size.width, height: bounds.size.height) + .offset(x: translation) + } + .clipped() + .gesture(RightGraphPan(enabled: properties.enabled == true && !settling) { + handle($0, width: bounds.size.width) + }) + } + .onAppear { graphScrollOffsets?.retain(properties.residentPageIDs ?? []) } + .onChange(of: properties.residentPageIDs) { _, ids in graphScrollOffsets?.retain(ids ?? []) } + .onChange(of: properties.pageID) { _, _ in + translation = 0; dragging = false; settling = false + } + .onChange(of: properties.targetID) { _, target in + if target == nil && !dragging { translation = 0; settling = false } + } + .onChange(of: properties.enabled) { _, enabled in + if enabled != true && dragging { + dragging = false + withAnimation(.smooth(duration: 0.2)) { translation = 0 } + emit("cancel") + } + } + } + } + #endif + private struct TitleRun: Decodable { let text: String let styles: [String] @@ -233,6 +358,14 @@ struct JournalSheetDismissButton: SwiftUI.View { var body: some SwiftUI.View { let properties = self.properties switch properties?.mode { + case .graphPages: + if let properties, context.childIDs.count == 2 { + #if os(iOS) + GraphPages(context: context, properties: properties, current: child(0), preview: child(1)) + #else + child(0) + #endif + } case .page: if let properties, context.childIDs.count == 3, properties.connecting != nil, properties.controls != nil { diff --git a/swift/JournalIcons.swift b/swift/JournalIcons.swift index 9cca2a6c..04e6bd41 100644 --- a/swift/JournalIcons.swift +++ b/swift/JournalIcons.swift @@ -33,6 +33,7 @@ let journalIconNames: [String] = [ "person.crop.circle", "photo", "plus", + "plus.circle", "questionmark.folder", "rectangle.portrait.and.arrow.right", "slider.horizontal.3", diff --git a/swift/JournalList.swift b/swift/JournalList.swift index 6269c5e5..cb9f3183 100644 --- a/swift/JournalList.swift +++ b/swift/JournalList.swift @@ -5,6 +5,46 @@ import Observation import UIKit #endif +struct JournalGraphScrollScope { + let pageID: String + let records: Bool +} +private struct JournalGraphScrollScopeKey: EnvironmentKey { + static let defaultValue: JournalGraphScrollScope? = nil +} +extension EnvironmentValues { + var journalGraphScrollScope: JournalGraphScrollScope? { + get { self[JournalGraphScrollScopeKey.self] } + set { self[JournalGraphScrollScopeKey.self] = newValue } + } +} + +/// Native offsets survive graph view remounts, bounded by live runtime scopes. +/// Retired views cannot repopulate an account or graph after its removal. +@MainActor final class JournalGraphScrollOffsets { + private var live: Set = [] + private var offsets: [String: [String: CGFloat]] = [:] + func retain(_ pages: [String]) { + live = Set(pages) + offsets = offsets.filter { live.contains($0.key) } + } + func read(page: String, list: String) -> CGFloat? { offsets[page]?[list] } + func write(page: String, list: String, y: CGFloat) { + guard live.contains(page), y.isFinite else { return } + offsets[page, default: [:]][list] = max(0, y) + } +} + +private struct JournalGraphScrollOffsetsKey: EnvironmentKey { + static let defaultValue: JournalGraphScrollOffsets? = nil +} +extension EnvironmentValues { + var journalGraphScrollOffsets: JournalGraphScrollOffsets? { + get { self[JournalGraphScrollOffsetsKey.self] } + set { self[JournalGraphScrollOffsetsKey.self] = newValue } + } +} + /// SwiftUI host for the `journal-list` extension — the lui replacement for /// bonsai's `Native_list` family (grouped sections, disclosure rows, scroll /// position requests, visible-range paging, context actions). @@ -80,6 +120,7 @@ import UIKit let track_visible_range: Bool? let track_scroll_completion: Bool? let sync_enabled: Bool? + let retention_key: String? } /// One payload observation belongs to one mounted native list. Child-model @@ -352,6 +393,27 @@ import UIKit private enum Phase { case fadingOut, jumping, waitingForLayout, fadingIn } private var phase: Phase? private var pending: (id: UUID, token: Int64, complete: (Int64, String) -> Void)? + private var restoredOffset: (y: CGFloat, complete: (CGFloat) -> Void)? + func cancelRestore() { restoredOffset = nil } + func restoreOffset(_ y: CGFloat, complete: @escaping (CGFloat) -> Void) { + restoredOffset = (y, complete) + applyRestoredOffset() + } + func applyRestoredOffset() { + guard active, !userInteracting, let request = restoredOffset, + let scrollView, scrollView.window != nil, scrollView.contentSize.height > 0, + !scrollView.isTracking, !scrollView.isDragging else { return } + scrollView.layoutIfNeeded() + let maximum = max(0, scrollView.contentSize.height - scrollView.bounds.height + + scrollView.adjustedContentInset.top + scrollView.adjustedContentInset.bottom) + let y = min(maximum, max(0, request.y)) + restoredOffset = nil + UIView.performWithoutAnimation { + scrollView.setContentOffset(CGPoint(x: scrollView.contentOffset.x, + y: y - scrollView.adjustedContentInset.top), animated: false) + } + request.complete(y) + } private var reduced = false private var deadline: Task? #endif @@ -359,13 +421,21 @@ import UIKit func accept(_ token: Int64) -> Bool { guard active, token > handledToken else { return false } handledToken = token + #if os(iOS) + cancelRestore() + #endif cancel() return true } func interactionChanged(_ interacting: Bool) { userInteracting = interacting - if interacting { cancel() } + if interacting { + #if os(iOS) + cancelRestore() + #endif + cancel() + } } func geometryChanged(_ top: Bool) { @@ -492,6 +562,7 @@ import UIKit } func resolveIfNeeded() { + owner?.applyRestoredOffset() guard window != nil, owner?.scrollView?.window == nil, !resolving else { return } resolving = true DispatchQueue.main.async { [weak self] in @@ -503,6 +574,7 @@ import UIKit while let candidate = ancestor, candidate !== self.window { if let scrollView = self.findScrollView(candidate) { owner.scrollView = scrollView + owner.applyRestoredOffset() return } ancestor = candidate.superview @@ -542,6 +614,13 @@ import UIKit @StateObject private var topScroll = TopScroll() @State private var pullEligible = false @State private var pullHint = 0 + @State private var retainedPosition = ScrollPosition() + @State private var restoringPosition = true + @State private var restorationY: CGFloat? + @State private var scrollMounted = false + @State private var boundScrollIdentity: String? + @Environment(\.journalGraphScrollScope) private var graphScrollScope + @Environment(\.journalGraphScrollOffsets) private var graphScrollOffsets @Environment(\.accessibilityReduceMotion) private var reduceMotion init(context: LUIAppleExtensionViewContext) { @@ -553,6 +632,38 @@ import UIKit prepared.snapshot?.properties } + private var scrollIdentity: String? { + guard let scope = graphScrollScope, let key = properties?.retention_key else { return nil } + return "\(context.nodeID):\(scope.pageID):\(key)" + } + + private func restoreScrollPosition() { + restoringPosition = true + restorationY = nil + boundScrollIdentity = scrollIdentity + retainedPosition = ScrollPosition() + #if os(iOS) + topScroll.cancelRestore() + #endif + if let scope = graphScrollScope, let key = properties?.retention_key, + let y = graphScrollOffsets?.read(page: scope.pageID, list: key) { + restorationY = y + #if os(iOS) + // SwiftUI List does not honor ScrollPosition point restoration here. + // Reuse its existing native scroll view without replacing the List. + let identity = boundScrollIdentity + topScroll.restoreOffset(y) { resolved in + guard scrollMounted, boundScrollIdentity == identity else { return } + restorationY = resolved + } + #else + retainedPosition.scrollTo(y: y) + #endif + } else { + restoringPosition = false + } + } + private func emit(_ event: Event) { guard let data = try? JSONEncoder().encode(event) else { return } JournalExtensions.emit(context: context, payload: data) @@ -607,6 +718,8 @@ import UIKit private func applyScrollRequest(_ request: ScrollRequest) { guard let properties, topScroll.accept(request.token) else { return } + restorationY = nil + restoringPosition = false if request.target.row_path.isEmpty { guard properties.sections.first?.key == request.target.section else { completeScroll(request.token, "missing_target") @@ -759,9 +872,12 @@ import UIKit } private func scrollPhaseChanged(from previous: ScrollPhase, to phase: ScrollPhase) { + guard scrollMounted, boundScrollIdentity == scrollIdentity else { return } let wasTracking = previous == .tracking || previous == .interacting let tracking = phase == .tracking || phase == .interacting if tracking && !wasTracking { + restorationY = nil + restoringPosition = false pullEligible = properties?.sync_enabled == true && topScroll.atTop pullHint = 0 } @@ -803,13 +919,22 @@ import UIKit } } .modifier(ListStyleModifier(style: style)) + .scrollPosition($retainedPosition) #if os(iOS) .overlay(TopScrollAccess(owner: topScroll).ignoresSafeArea().allowsHitTesting(false).accessibilityHidden(true)) #endif .onScrollGeometryChange(for: CGFloat.self) { geometry in geometry.contentOffset.y + geometry.contentInsets.top } action: { _, offset in + guard scrollMounted, boundScrollIdentity == scrollIdentity else { return } topScroll.geometryChanged(offset <= 1) + if let target = restorationY, abs(offset - target) <= 1 { + restorationY = nil + restoringPosition = false + } + if !restoringPosition, let scope = graphScrollScope, scope.records, let key = properties?.retention_key { + graphScrollOffsets?.write(page: scope.pageID, list: key, y: offset) + } if pullEligible { // Only the three semantic hint states invalidate the view. let hint = offset <= -72 ? 2 : offset <= -20 ? 1 : 0 @@ -841,14 +966,23 @@ import UIKit .onAppear { topScroll.active = true prepared.bind(context) + scrollMounted = true + restoreScrollPosition() scrollProxy = proxy performPendingScroll() observeScrollRequests() } } .onDisappear { + scrollMounted = false + boundScrollIdentity = nil + restoringPosition = true + restorationY = nil cancelPull() topScroll.active = false + #if os(iOS) + topScroll.cancelRestore() + #endif topScroll.interactionChanged(false) topScroll.cancel() pendingScroll = nil @@ -857,6 +991,8 @@ import UIKit topScroll.geometryChanged(false) } .onChange(of: context.nodeID) { _, _ in + restoringPosition = true + boundScrollIdentity = nil cancelPull() topScroll.interactionChanged(false) topScroll.cancel() @@ -865,8 +1001,12 @@ import UIKit topScroll.handledToken = 0 topScroll.geometryChanged(false) prepared.bind(context) + restoreScrollPosition() observeScrollRequests() } + .onChange(of: scrollIdentity) { _, _ in + if scrollMounted && boundScrollIdentity != scrollIdentity { restoreScrollPosition() } + } .onChange(of: reduceMotion) { _, reduced in topScroll.reduceMotionChanged(reduced) } .onChange(of: pendingScroll?.request.token) { _, _ in performPendingScroll() } } diff --git a/swift/JournalRuntimeHost.swift b/swift/JournalRuntimeHost.swift index ff0b9f00..6bfaa143 100644 --- a/swift/JournalRuntimeHost.swift +++ b/swift/JournalRuntimeHost.swift @@ -17,6 +17,7 @@ struct JournalRuntimeHost: View { @State private var runtime: JournalRuntime? @State private var startingRuntime: JournalRuntime? @State private var fallbackClock = JournalStartupClock() + @State private var graphScrollOffsets = JournalGraphScrollOffsets() @Environment(\.journalStartupClock) private var inheritedClock @State private var failure: Error? @State private var retry = 0 @@ -33,7 +34,7 @@ struct JournalRuntimeHost: View { } actions: { Button("Retry") { guard runtime?.stop() != false, startingRuntime?.stop() != false else { return } - runtime = nil; startingRuntime = nil; failure = nil; clock.restart(); retry += 1 + runtime = nil; startingRuntime = nil; failure = nil; graphScrollOffsets.retain([]); clock.restart(); retry += 1 } .accessibilityIdentifier("journal-runtime-retry") } @@ -60,6 +61,7 @@ struct JournalRuntimeHost: View { #endif } .environment(\.journalStartupClock, clock) + .environment(\.journalGraphScrollOffsets, graphScrollOffsets) .background(JournalEnvironmentObserver { platform.pushEnvironment($0) }) .task(id: retry) { if runtime == nil && startingRuntime == nil { @@ -100,6 +102,7 @@ struct JournalRuntimeHost: View { } } .onDisappear { + graphScrollOffsets.retain([]) runtime?.stop() if startingRuntime?.stop() == false { failure = startingRuntime?.failure } } diff --git a/test/application_view_test.ml b/test/application_view_test.ml index e2165127..d897acd1 100644 --- a/test/application_view_test.ml +++ b/test/application_view_test.ml @@ -1,3 +1,15 @@ +(* Existing single-graph service fixtures retain their behavior while accepting + the production caller's explicit graph owner. Multi-graph routing is tested + through the public Worker reducer and real service. *) +let fixture_request = function + | Logseq_db_worker_lui.Logseq_db_worker_lui_service.Graph_request_at (_,request) -> Logseq_db_worker_lui.Logseq_db_worker_lui_service.Graph_request request + | Get_graph_state_at _ -> Get_graph_state + | Asset_command_at (owner,command) -> Asset_command {graph_generation=owner.graph_generation;command} + | Import_asset_at (owner,source) -> Import_asset {graph_generation=owner.graph_generation;source} + | Client_command (Focus_resident_graph id) -> Client_command (Select_graph id) + | request -> request +;; + module Root_state = struct include Application.Root_navigation let step state event = @@ -130,7 +142,7 @@ let test_missing_snapshot_keeps_graph_lifecycle_visible () = let test_diagnostic_phase_rows_retain_current_error () = let message = "Graph schema 65.29 is not supported. Journal requires schema 65.33 or later." in let snapshot : Service.snapshot = - { sync_phase = Failed; catalog = []; selected_graph = None; applied_server_t = None + { sync_phase = Failed; catalog = []; resident_graphs=[];resident_scopes=[]; selected_graph = None; applied_server_t = None ; unlock = Logseq_sync_pure_reducer.Core.Unlock_inactive ; timeline_presentation_pending = false; local_deletion = None; last_error_attention = None; last_error = Some message ; startup = { authenticated = true; local_access=Service.Local_available; network_phase=Service.Online_by_user; catalog_loading = false; awaiting_selection = false @@ -153,7 +165,7 @@ let test_diagnostic_identity_comes_from_worker () = let selected = uuid "60000000-0000-4000-8000-000000000001" in let actual = uuid "60000000-0000-4000-8000-000000000002" in let snapshot : Service.snapshot = - { sync_phase = Offline; catalog = []; selected_graph = Some selected + { sync_phase = Offline; catalog = []; resident_graphs=[];resident_scopes=[]; selected_graph = Some selected ; applied_server_t = None; unlock = Logseq_sync_pure_reducer.Core.Unlock_inactive ; timeline_presentation_pending = false ; local_deletion = None; last_error_attention = None; last_error = None @@ -705,6 +717,7 @@ let run_favorites_native_visibility { snapshot = { sync_phase = Current ; catalog = [] + ; resident_graphs=[];resident_scopes=[] ; selected_graph = Some (uuid 900) ; applied_server_t = Some (server_cursor_of_int 0) ; unlock = Logseq_sync_pure_reducer.Core.Unlock_inactive @@ -744,7 +757,8 @@ let run_favorites_native_visibility (Service.Client_state_changed manager); Ok ()) ~handle:(fun context () request -> - match request with + match fixture_request request with + | Graph_request_at _ | Get_graph_state_at _ | Asset_command_at _ | Import_asset_at _ -> Error "Fixture normalization failed" | Service.Inspect_local_copy _ | Service.Delete_local_copy _ -> Error "Unused fixture local-copy query" | Service.Get_graph_state -> if Atomic.get import_probe then Atomic.set import_probed true; @@ -4195,6 +4209,7 @@ let test_reference_outer_terminals cancel = { snapshot = { sync_phase = Current ; catalog = [] + ; resident_graphs=[];resident_scopes=[] ; selected_graph = Some (uuid 900) ; applied_server_t = Some (server_cursor_of_int 0) ; unlock = Logseq_sync_pure_reducer.Core.Unlock_inactive @@ -4240,7 +4255,8 @@ let test_reference_outer_terminals cancel = ; outcome })) in - match request with + match fixture_request request with + | Graph_request_at _ | Get_graph_state_at _ | Asset_command_at _ | Import_asset_at _ -> Error "Fixture normalization failed" | Service.Inspect_local_copy _ | Service.Delete_local_copy _ -> Error "Unused fixture local-copy query" | Service.Get_graph_state -> if Atomic.exchange refresh false @@ -4805,7 +4821,7 @@ let test_favorite_page_uses_shared_reference_lookup () = ; order = "a"; created_at_ms = 0L; updated_at_ms = 0L; refs = []; tags = []; properties = [] } in let manager : Service.state = { snapshot = - { sync_phase = Current; catalog = []; selected_graph = Some (uuid 9) + { sync_phase = Current; catalog = []; resident_graphs=[];resident_scopes=[]; selected_graph = Some (uuid 9) ; applied_server_t = Some (server_cursor_of_int 0); unlock = Logseq_sync_pure_reducer.Core.Unlock_inactive ; timeline_presentation_pending = false ; startup = { authenticated = true; local_access=Service.Local_available; network_phase=Service.Online_by_user; catalog_loading = false; awaiting_selection = false @@ -4818,7 +4834,7 @@ let test_favorite_page_uses_shared_reference_lookup () = ~init:(fun context _ -> W.Session_context.emit context ~topic:Service.manager_topic (Service.Client_state_changed manager); Ok ()) - ~handle:(fun _ () -> function + ~handle:(fun _ () request -> match fixture_request request with | Service.Get_graph_state -> Ok (Service.Graph_state { generation = 1; graph_id = Some (uuid 9); phase = Graph_open; error = None }) | Client_command _ -> Ok Service.Client_command_completed @@ -5350,6 +5366,7 @@ let unlock_fixture ?(worker_failure = false) ?(queue_full = false) ?(fail_init = let manager : Service.state = { snapshot = { sync_phase = Offline + ; resident_graphs=[];resident_scopes=[] ; catalog = [ { graph_id ; name = "Private Journal" @@ -5417,7 +5434,8 @@ let unlock_fixture ?(worker_failure = false) ?(queue_full = false) ?(fail_init = ~topic:Service.manager_topic (Service.Client_state_changed value) in - match request with + match fixture_request request with + | Graph_request_at _ | Get_graph_state_at _ | Asset_command_at _ | Import_asset_at _ -> Error "Fixture normalization failed" | Service.Inspect_local_copy _ | Service.Delete_local_copy _ -> Error "Unused fixture local-copy query" | Service.Get_graph_state -> if queue_full && Atomic.fetch_and_add graph_reads 1 > 0 @@ -6269,7 +6287,45 @@ let test_application_token_control ~saturated ~failure () = (* Picker presentation and native controls are owned by Application; Root_navigation has no public picker event. Exercise its mounted public hooks and worker lane. *) +let test_picker_presence_native_patch_lifecycle () = + let module M = Journal_manager_view in + let ids=List.init 4 (fun n->Printf.sprintf "96000000-0000-4000-8000-%012d" (n+1)) in + let uuid id=Logseq_db_types.Graph_types.Uuid.of_string id |> Result.get_ok in + let live : M.picker_live={residents=List.filteri(fun n _->n<3)ids;replacement=None;status=""; + authenticated=true;checking=false;deleting=None;presence=(fun _->None)} in + let graphs=List.map(fun id->({graph_id=uuid id;name="Synthetic graph " ^ id; + schema={major=65;minor=33;exact=true};encrypted=false}:Service.graph))ids |> List.to_seq |> Rrbvec.of_seq in + let snapshot=M.{graphs;selected_graph=Some(uuid(List.hd ids));live} in + let nodes=Hashtbl.create 256 and parents=Hashtbl.create 256 in + let teardown=track_json_wire_teardown ~parents (Hashtbl.remove nodes) in + let backend : Lui_protocol.backend={backend_profile=Lui_native_bridge.profile 2 2; + apply_batch=(fun batch-> + let open Yojson.Safe.Util in + Lui_wire.encode_batch batch |> Yojson.Safe.from_string |> member "ops" |> to_list + |> List.iter(fun op-> + let kind=member "op" op |> to_string in + let require field=let id=member field op |> to_int in + if not(Hashtbl.mem nodes id) then failwith(Printf.sprintf "native picker %s references missing %s %d" kind field id) in + (match kind with + | "create-node" | "create-extension" -> Hashtbl.replace nodes (member "id" op |> to_int) () + | "insert-child" | "move-child" | "remove-child" -> require "parent";require "child" + | "set-prop" | "set-extension-prop" | "detach-subtree" | "drop-node" | "drop-extension" -> require "id" + | _->()); + teardown op);true)} in + let app=Lui_app.create_with_extensions backend Journal_lui_native.registry live (fun _ next->next) + (fun _ signal _->Journal_view.mount(Journal_view.View.Body.Private.to_widget + (M.view ~picker_signal:signal (M.Picker snapshot) ~on_action:ignore))) in + Fun.protect ~finally:(fun()->ignore(Lui_app.dispose app)) (fun()-> + ignore(Lui_app.start app);ignore(Lui_app.flush app); + List.iter(fun presence-> + ignore(Lui_app.send app {live with presence=(fun _->presence)}); + ignore(Lui_app.flush app)) + [Some(Ok Service.Downloaded);Some(Ok Service.Not_downloaded);Some(Error "Synthetic presence error"); + Some(Ok Service.Downloaded);None;Some(Ok Service.Not_downloaded);Some(Ok Service.Downloaded)]) +;; + let test_graph_picker_controls scenario () = + let reordered=scenario=`Reordered_open || scenario=`Reordered_full in let module W = Logseq_db_worker_lui.Journal_worker in let module P = Logseq_db_worker.Protocol in let uuid n = Logseq_db_types.Graph_types.Uuid.of_string @@ -6278,15 +6334,22 @@ let test_graph_picker_controls scenario () = let long_name = "Synthetic very long graph name — 工作笔记 and attachments across multiple lines" in let graph graph_id name : Service.graph = { graph_id; name; schema = { major = 65; minor = 33; exact = true }; encrypted = false } in + let resident_scope graph_id graph_generation : Service.asset_scope = + {account={managed_sync_origin=Uri.of_string "https://example.invalid";user_id="synthetic"; + account_generation=1;presentation_generation=1;lifecycle_generation=0L};graph_id;graph_generation} in let manager : Service.state = { snapshot = - { sync_phase = Current; catalog = [graph first "Current synthetic graph"; graph second long_name] + { sync_phase = Current; + resident_graphs=(if reordered then [first] else if scenario=`Current then [first;second;uuid 3] else if scenario=`Retry_local || scenario=`Retry_graph_phase then [first;second] else []); + resident_scopes=(if reordered then [resident_scope first 1] else if scenario=`Current then [resident_scope first 1;resident_scope second 2;resident_scope (uuid 3) 3] else if scenario=`Retry_local || scenario=`Retry_graph_phase then [resident_scope first 1;resident_scope second 2] else []); + catalog = [graph first "Current synthetic graph"; graph second long_name] + @ (if scenario=`Current then [graph (uuid 3) "Third synthetic graph";graph (uuid 4) "Fourth synthetic graph"] else []) ; selected_graph = Some first; applied_server_t = Some (server_cursor_of_int 0) ; unlock = Logseq_sync_pure_reducer.Core.Unlock_inactive ; timeline_presentation_pending = false - ; startup = { authenticated = scenario <> `Offline; local_access=Service.Local_available; network_phase=(if scenario = `Offline then Service.Local_only else Service.Online_by_user); catalog_loading = false; awaiting_selection = false + ; startup = { authenticated = scenario <> `Offline; local_access=(if scenario=`Retry_local then Service.Local_unavailable Local_restore_failed else Service.Local_available); network_phase=(if scenario = `Offline then Service.Local_only else Service.Online_by_user); catalog_loading = false; awaiting_selection = false ; restoring_local = false; bootstrapping = false; awaiting_e2ee_password = false - ; failure = None; account_generation = 1; graph_generation = 1; presentation_generation = 1 } + ; failure = (if scenario=`Retry_local then Some Service.During_local_restore else None); account_generation = 1; graph_generation = 1; presentation_generation = 1 } ; last_error_attention = None; last_error = None; local_deletion = None } ; diagnostics = { groups = [] } } in let current = Atomic.make manager and commands = Atomic.make [] in @@ -6301,6 +6364,8 @@ let test_graph_picker_controls scenario () = let graph_read_cancelling = Atomic.make false and release_graph_terminal = Atomic.make false in let graph_read_worker = Atomic.make None in let graph_opens = Atomic.make 0 in + let early_graph_observed=Atomic.make false in + let refresh_full=Atomic.make false and release_refresh=Atomic.make false in let root_renders = ref 0 in let client = ref None in let service = W.Service.create ~push_topic_count:6 @@ -6313,7 +6378,8 @@ let test_graph_picker_controls scenario () = let publish value = Atomic.set current value; W.Request_context.emit context ~topic:Service.manager_topic (Service.Client_state_changed value) in - match request with + match (match request with Service.Client_command _ when scenario=`Retry_local || scenario=`Retry_graph_phase -> request | _ -> fixture_request request) with + | Graph_request_at _ | Get_graph_state_at _ | Asset_command_at _ | Import_asset_at _ -> Error "Fixture normalization failed" | Service.Inspect_local_copy request -> let index = Atomic.fetch_and_add inspections 1 in if scenario = `Late_presence && index = 0 @@ -6338,8 +6404,10 @@ let test_graph_picker_controls scenario () = | Service.Get_graph_state -> let value = Atomic.get current in Ok (Service.Graph_state { generation = value.snapshot.startup.graph_generation - ; graph_id = value.snapshot.selected_graph; phase = Graph_open; error = None }) + ; graph_id = value.snapshot.selected_graph; phase = (if scenario=`Retry_local || scenario=`Retry_graph_phase then Graph_failed else Graph_open); error = None }) | Client_command command -> + if scenario=`Reordered_full && command=Service.Set_foreground true && Atomic.get refresh_full then + while not(Atomic.get release_refresh) do Eio.Fiber.yield () done; Atomic.set commands (command :: Atomic.get commands); let previous = Atomic.get current in (match command with @@ -6352,10 +6420,20 @@ let test_graph_picker_controls scenario () = | Refresh_catalog _ when scenario = `Refresh_failure || scenario = `Refresh_after_presence_failure -> publish { previous with snapshot = { previous.snapshot with last_error = Some "Synthetic catalog refresh failed. Try again." } } + (* Root_navigation has no manager/worker delivery input. Exercise + this ordering through the public application service boundary. *) + | Configure_resident_graphs ids when reordered -> + W.Request_context.emit context ~topic:Service.graph_state_topic + (Service.Graph_states [resident_scope second 2, + {generation=2;graph_id=Some second;phase=Graph_open;error=None}]); + while not(Atomic.get early_graph_observed) do Eio.Fiber.yield () done; + publish {previous with snapshot={previous.snapshot with resident_graphs=ids; + resident_scopes=[resident_scope first 1;resident_scope second 2]}} | Select_graph id -> let generation = previous.snapshot.startup.graph_generation + 1 in publish { previous with snapshot = { previous.snapshot with selected_graph = Some id ; startup = { previous.snapshot.startup with awaiting_selection = false; graph_generation = generation } } }; + if not reordered then W.Request_context.emit context ~topic:Service.graph_state_topic (Service.Graph_state_changed { generation; graph_id = Some id; phase = Graph_open; error = None }) | _ -> ()); @@ -6401,6 +6479,19 @@ let test_graph_picker_controls scenario () = ~on_client:(fun value -> client := Some value; W.on_event value (function + | W.Push {payload=Service.Client_state_changed state;_} + when scenario=`Reordered_full && state.snapshot.selected_graph=Some second + && not(Atomic.get refresh_full) -> + (* Fill the real bounded Worker lane before Application receives the + focus publication; the first accepted filler holds the service. *) + Atomic.set refresh_full true; + let rec fill count = if count>1024 then failwith "fixture lane did not fill" else + match W.send value (Service.Client_command (Set_foreground true)) with + | W.Accepted _ -> fill (count+1) + | W.Full -> () + | Not_ready | Stopping -> failwith "fixture worker not ready" in + fill 0 + | W.Push {payload=Service.Graph_states _;_} -> Atomic.set early_graph_observed true | W.Response { outcome = Completed (Service.Local_copy_deleted _); _ } -> deletion_terminal := true | W.Response { request_id; outcome = Cancelled; _ } -> @@ -6411,13 +6502,18 @@ let test_graph_picker_controls scenario () = | _ -> ())) service in let props = Hashtbl.create 128 in let retained_select = ref None and select_detached = ref false in - let teardown = track_json_wire_teardown (fun id -> + let page_parents=Hashtbl.create 128 in + let teardown = track_json_wire_teardown ~parents:page_parents (fun id -> if !retained_select = Some id then select_detached := true; Hashtbl.remove props id) in let consume source = if source <> "" then let open Yojson.Safe.Util in Yojson.Safe.from_string source |> member "ops" |> to_list |> List.iter (fun op -> + let kind = member "op" op |> to_string in + if List.mem kind ["drop-node";"drop-extension";"detach-subtree"] then + Alcotest.(check bool) "picker teardown only references mounted native nodes" true + (Hashtbl.mem props (member "id" op |> to_int)); teardown op; match member "op" op |> to_string with | "create-node" | "create-extension" -> @@ -6425,8 +6521,15 @@ let test_graph_picker_controls scenario () = | "set-prop" | "set-extension-prop" -> let id = member "id" op |> to_int in let key = member "property" op |> to_string in - let old = Option.value (Hashtbl.find_opt props id) ~default:[] in + (* Swift rejects mutations of detached native nodes. Do not silently + recreate their shadow entries when the shell and picker reconcile. *) + Alcotest.(check bool) "picker patch only updates live native nodes" true (Hashtbl.mem props id); + let old = Hashtbl.find props id in Hashtbl.replace props id ((key, member "value" op) :: List.remove_assoc key old) + | "insert-child" | "move-child" -> + List.iter (fun field -> Alcotest.(check bool) + ("picker patch references live native " ^ field) true + (Hashtbl.mem props (member field op |> to_int))) ["parent";"child"] | _ -> ()) in let find key value = Hashtbl.fold (fun id values found -> if List.assoc_opt key values = Some (`String value) then Some id else found) props None in @@ -6446,6 +6549,7 @@ let test_graph_picker_controls scenario () = ~response_budget_bytes:P.maximum_response_bytes ~default_page_size:P.default_page_size |> Result.get_ok |> Journal_startup.encode |> Result.get_ok |> Bytes.to_string in Fun.protect ~finally:(fun () -> + Atomic.set release_refresh true; Atomic.set finish_deletion true; Atomic.set release_automatic_inspection true; Atomic.set release_graph_read true; @@ -6453,17 +6557,41 @@ let test_graph_picker_controls scenario () = ignore (hooks.dispose ()); Option.iter Logseq_db_worker_lui.Journal_worker_runtime.stop !client) (fun () -> consume (hooks.init 2 2 config); + if scenario=`Retry_local || scenario=`Retry_graph_phase then ( + wait "failed resident offers local retry" (fun()->Option.is_some(find "accessibility-identifier" "graph-picker-retry")); + press(node "graph-picker-retry"); + wait "local retry actually restarts resident open" (fun()->List.exists(function Service.Select_graph id->id=first|_->false)(Atomic.get commands)); + Alcotest.(check bool) "retry retains second resident" true (List.mem second (Atomic.get current).snapshot.resident_graphs) + ) else ( wait "Switch graph action mounts" (fun () -> Option.is_some (find "text" "Switch graph")); + if scenario=`Current then + wait "native swipe admission follows graph readiness" (fun () -> + Hashtbl.to_seq_values props |> Seq.exists(fun fields->match List.assoc_opt "payload" fields with + | Some(`String payload)->(try let open Yojson.Safe.Util in let json=Yojson.Safe.from_string payload in + member "mode" json=`String "graph-pages" && member "enabled" json=`Bool true with _->false) + | _->false)); if scenario = `Late_graph_cancel then ( wait "Favorites action mounts" (fun () -> Option.is_some (find "accessibility-label" "Favorites")); press (Option.get (find "accessibility-label" "Favorites")); wait "old graph Favorite read is genuinely in flight" (fun () -> Atomic.get graph_read_started)); press (Option.get (find "text" "Switch graph")); + let page=Hashtbl.to_seq props |> Seq.find_map(fun(id,fields)-> + match List.assoc_opt "payload" fields with + | Some (`String payload) when String.contains payload 'g' -> + (try if Yojson.Safe.Util.(Yojson.Safe.from_string payload |> member "mode" |> to_string)="graph-pages" then Some id else None with _->None) + | _->None) |> Option.get in + Alcotest.(check int) "native page retains both content and empty preview slots" 2 + (Hashtbl.to_seq_values page_parents |> Seq.filter((=)page) |> Seq.length); wait "graph list mounts" (fun () -> Option.is_some (find "accessibility-identifier" "graph-picker-list")); + if scenario = `Current then ( + let list = Option.get (find "_extension" "journal-list") in + consume (hooks.extension_event list "event" + {|{"id":1,"payload":"{\"type\":\"visible_range\",\"first\":3,\"last\":7}"}|}); + wait "visible resident local presence checks complete" (fun () -> Atomic.get inspections >= 4)); if scenario = `Late_presence then ( let list = Option.get (find "_extension" "journal-list") in consume (hooks.extension_event list "event" - {|{"id":1,"payload":"{\"type\":\"visible_range\",\"first\":1,\"last\":2}"}|}); + {|{"id":1,"payload":"{\"type\":\"visible_range\",\"first\":4,\"last\":5}"}|}); wait "automatic inspection is held in flight" (fun () -> Atomic.get inspections = 1)); match scenario with | `Offline -> @@ -6503,6 +6631,7 @@ let test_graph_picker_controls scenario () = for _ = 1 to 10 do range (); consume (hooks.pump ()) done; Alcotest.(check int) "same viewport reuses presence observations" 2 (Atomic.get inspections); Alcotest.(check int) "presence does not reconstruct the graph catalog" before !root_renders + | `Retry_local | `Retry_graph_phase -> () | `Current -> Alcotest.(check bool) "current marker stays in catalog row" true (Option.is_some (find "text" "Current graph")); @@ -6519,6 +6648,16 @@ let test_graph_picker_controls scenario () = | Service.Return_to_graph_picker -> true | _ -> false) (Atomic.get commands)) + | `Reordered_open | `Reordered_full -> + let before=Atomic.get graph_opens in + press(node ("graph-picker:" ^ Logseq_db_types.Graph_types.Uuid.to_string second)); + wait "early graph state was observed before membership admission" (fun()->Atomic.get early_graph_observed); + if scenario=`Reordered_full then ( + wait "real worker queue is full during focus" (fun()->Atomic.get refresh_full); + for _=1 to 4 do consume(hooks.pump()) done; + Atomic.set release_refresh true); + wait "focused graph presents even when its initial push preceded membership" (fun()-> + (Atomic.get current).snapshot.selected_graph=Some second && Atomic.get graph_opens>before) | `Select -> press (node ("graph-picker:" ^ Logseq_db_types.Graph_types.Uuid.to_string second)); wait "name selection reaches worker" (fun () -> @@ -6646,7 +6785,7 @@ let test_graph_picker_controls scenario () = | `Current_menu -> ignore (node ("graph-picker-options:" ^ Logseq_db_types.Graph_types.Uuid.to_string first)); Alcotest.(check bool) "current protection explains switching first" true - (Option.is_some (find "text" "Switch graphs first."))) + (Option.is_some (find "text" "Switch graphs first.")))) ;; (* Compose the production runtime's public completions with the Application @@ -6829,6 +6968,19 @@ let test_worker_admission_owner () = require (dispatch f request=W.Full) "duplicate protocol ID accepted"; require (List.length !(f.sent)=1 && !(f.refused)=[]) "duplicate entered transport or refusal callback"; require (!(f.observed)=[request]) "accepted observer changed"); + check "same protocol UUID is independently admitted per graph lifetime" (fun () -> + let f=F.create () and request=F.request 1 in + let scope n : Service.timeline_scope = + {startup={account_generation=1;presentation_generation=1};graph_id=(F.request n).request_id;graph_generation=n} in + let first=scope 1 and second=scope 2 in + require (A.dispatch f.owner (Graph_request_at(first,request))=W.Accepted(wid 1)) "first graph refused"; + require (A.dispatch f.owner (Graph_request_at(second,request))=W.Accepted(wid 2)) "second graph UUID collided"; + require (A.dispatch f.owner (Graph_request_at(first,request))=W.Full) "same graph duplicate admitted"; + A.cancel_graph_scope f.owner (Some first); + require (!(f.cancelled)=[wid 1]) "closing one graph cancelled another"; + require (A.dispatch f.owner (Graph_request_at(second,request))=W.Full) "closing first erased second admission"; + ignore(A.retire f.owner (wid 1)); + require (A.dispatch f.owner (Graph_request_at(second,request))=W.Full) "late completion erased second admission"); check "real refusals never reserve lease" (fun () -> List.iter (fun refusal -> let f=F.create () in let request=F.request 1 in @@ -6946,11 +7098,16 @@ let () = Alcotest.test_case "typed native reason completes local discovery" `Quick (fun () -> test_local_account_failure_retries_local_discovery ~typed_reason:true ()); Alcotest.test_case "retired native scope cannot fail local discovery" `Quick (fun () -> test_local_account_failure_retries_local_discovery ~retired_scope:true ())]) ; ( "graph picker", [ + Alcotest.test_case "presence updates preserve native node lifecycle" `Quick test_picker_presence_native_patch_lifecycle; + Alcotest.test_case "failed resident local retry restarts open" `Quick (test_graph_picker_controls `Retry_local); + Alcotest.test_case "failed worker local retry restarts retained mirror" `Quick (test_graph_picker_controls `Retry_graph_phase); Alcotest.test_case "offline deletion is disabled with explanation" `Quick (test_graph_picker_controls `Offline); Alcotest.test_case "refresh failure replaces old presence failure" `Quick (test_graph_picker_controls `Refresh_after_presence_failure); Alcotest.test_case "catalog refresh failure is visible" `Quick (test_graph_picker_controls `Refresh_failure); - Alcotest.test_case "current marker and long names" `Quick (test_graph_picker_controls `Current); + Alcotest.test_case "resident picker keeps native patch references alive" `Quick (test_graph_picker_controls `Current); Alcotest.test_case "close retains current session" `Quick (test_graph_picker_controls `Close); + Alcotest.test_case "focus graph-state refresh survives full Worker lane" `Quick (test_graph_picker_controls `Reordered_full); + Alcotest.test_case "graph open before membership still presents after focus" `Quick (test_graph_picker_controls `Reordered_open); Alcotest.test_case "name switches graph" `Quick (test_graph_picker_controls `Select); Alcotest.test_case "old graph cancellation cannot publish a new-scope error" `Quick (test_graph_picker_controls `Late_graph_cancel); diff --git a/test/journal_routes_test.ml b/test/journal_routes_test.ml index cfeb4617..181742c9 100644 --- a/test/journal_routes_test.ml +++ b/test/journal_routes_test.ml @@ -4194,6 +4194,36 @@ let tests = ] ;; + +let test_resident_focus_preserves_graph_drafts_and_saving () = + let module R=Root_state in + let id n=Logseq_db_types.Graph_types.Uuid.of_string (Printf.sprintf "77777777-7777-4777-8777-%012d" n) |> Result.get_ok in + require (R.next_graph ~current:(id 1) [id 1]=None) "one graph intercepted cycle"; + require (R.next_graph ~current:(id 2) [id 1;id 2]=Some(id 1)) "two graph wrap failed"; + require (R.next_graph ~current:(id 3) [id 1;id 2;id 3]=Some(id 1)) "three to one wrap failed"; + let focus state n=R.step state (Graph_focused {generation=n;graph_id=id n}) in + let one=focus (R.create ~graph_generation:1) 1 |> fun s->R.step s Capture_opened + |> fun s->R.step s (Capture_edited "draft one") in + let two=focus one 2 |> fun s->R.step s Capture_opened |> fun s->R.step s (Capture_edited "draft two") in + let three=focus two 3 |> fun s->R.step s Capture_opened |> fun s->R.step s (Capture_edited "draft three") in + List.iter (fun (n,text)-> + let restored=focus three n in + require (Option.map Journal_capture.source (R.capture restored)=Some text) "resident draft crossed or disappeared") + [1,"draft one";2,"draft two";3,"draft three"]; + let capture=Option.get (R.capture one) in + let saving,request=Journal_capture.admit_save capture + ~mutation_id:"77777777-7777-4777-9777-000000000001" + ~block_id:"77777777-7777-4777-a777-000000000001" ~sibling_order:"z" + ~calendar_generation:1L ~creation_time:(creation_time 1) in + require (Option.is_some request) "saving fixture did not admit"; + let saving=R.step one (Capture_admitted saving) in + let returned=focus (focus saving 2) 1 in + require (Option.map Journal_capture.phase (R.capture returned)=Some Journal_capture.Saving) + "visible focus interrupted in-flight Capture"; + let cleared=R.step three Account_cleared in + require (R.capture (focus cleared 1)=None) "logout retained resident draft" +let tests=tests @ ["resident focus keeps per-graph drafts and pending Capture",test_resident_focus_preserves_graph_drafts_and_saving] +;; let () = test_native_composer_dismissal_and_edit_fences () let () = test_collapse_retains_complete_capture () diff --git a/test/source_boundary_test.ml b/test/source_boundary_test.ml index fbb4ee25..9fac1f19 100644 --- a/test/source_boundary_test.ml +++ b/test/source_boundary_test.ml @@ -160,7 +160,7 @@ let test_sync_error_card_is_temporary_and_error_only root = then fail "sync-error timeout behavior is missing %S" required) [ "let sync_error_card_lifetime = 5." ; "let arm_sync_error_timer sequence =" - ; "let sync_error_timer_generation = ref 0 in" + ; "let sync_error_timer_generation = graph_ref (fun () -> 0) in" ; "schedule_after sync_error_card_lifetime" ; "!sync_error_timer_generation = generation" ] diff --git a/test/startup_test.ml b/test/startup_test.ml index 00b8ca3a..e8868472 100644 --- a/test/startup_test.ml +++ b/test/startup_test.ml @@ -94,6 +94,7 @@ let startup_snapshot Graph_service. { sync_phase ; catalog = [] + ; resident_graphs=[];resident_scopes=[] ; selected_graph = Some graph_id ; applied_server_t = Some (server_cursor_of_int 11) ; unlock = Logseq_sync_pure_reducer.Core.Unlock_inactive