Script 'mail_helper' called by obssrc
Hello community,

here is the log from the commit of package python-jupyter-server-ydoc for 
openSUSE:Factory checked in at 2026-05-28 17:27:38
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-jupyter-server-ydoc (Old)
 and      /work/SRC/openSUSE:Factory/.python-jupyter-server-ydoc.new.1937 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "python-jupyter-server-ydoc"

Thu May 28 17:27:38 2026 rev:6 rq:1355498 version:2.4.0

Changes:
--------
--- 
/work/SRC/openSUSE:Factory/python-jupyter-server-ydoc/python-jupyter-server-ydoc.changes
    2026-03-23 17:13:16.633447919 +0100
+++ 
/work/SRC/openSUSE:Factory/.python-jupyter-server-ydoc.new.1937/python-jupyter-server-ydoc.changes
  2026-05-28 17:28:59.580819910 +0200
@@ -1,0 +2,6 @@
+Thu May 28 01:49:07 UTC 2026 - Steve Kowalik <[email protected]>
+
+- Update to 2.4.0
+  * New subpackage version for jupyter-collaboration 4.4.0 
+
+-------------------------------------------------------------------

Old:
----
  jupyter_server_ydoc-2.2.1.tar.gz

New:
----
  jupyter_server_ydoc-2.4.0.tar.gz

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ python-jupyter-server-ydoc.spec ++++++
--- /var/tmp/diff_new_pack.UNBrQm/_old  2026-05-28 17:29:00.212846071 +0200
+++ /var/tmp/diff_new_pack.UNBrQm/_new  2026-05-28 17:29:00.212846071 +0200
@@ -25,14 +25,15 @@
 %bcond_with test
 %endif
 
-%define distversion 2.2.1
+%define distversion 2.4
 Name:           python-jupyter-server-ydoc%{psuffix}
-Version:        2.2.1
+Version:        2.4.0
 Release:        0
 Summary:        Jupyter server extension integrating collaborative shared 
models
 License:        BSD-3-Clause
 URL:            https://github.com/jupyterlab/jupyter-collaboration
 Source:         
https://files.pythonhosted.org/packages/source/j/jupyter_server_ydoc/jupyter_server_ydoc-%{version}.tar.gz
+BuildRequires:  %{python_module base >= 3.10}
 BuildRequires:  %{python_module hatchling >= 1.4.0}
 BuildRequires:  %{python_module pip}
 BuildRequires:  fdupes

++++++ jupyter_server_ydoc-2.2.1.tar.gz -> jupyter_server_ydoc-2.4.0.tar.gz 
++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/jupyter_server_ydoc-2.2.1/PKG-INFO 
new/jupyter_server_ydoc-2.4.0/PKG-INFO
--- old/jupyter_server_ydoc-2.2.1/PKG-INFO      2020-02-02 01:00:00.000000000 
+0100
+++ new/jupyter_server_ydoc-2.4.0/PKG-INFO      2020-02-02 01:00:00.000000000 
+0100
@@ -1,6 +1,6 @@
 Metadata-Version: 2.4
 Name: jupyter-server-ydoc
-Version: 2.2.1
+Version: 2.4.0
 Summary: jupyter-server extension integrating collaborative shared models.
 Project-URL: Documentation, 
https://jupyterlab-realtime-collaboration.readthedocs.io/
 Project-URL: Repository, https://github.com/jupyterlab/jupyter-collaboration
@@ -74,25 +74,24 @@
 Classifier: Intended Audience :: System Administrators
 Classifier: License :: OSI Approved :: BSD License
 Classifier: Programming Language :: Python
-Classifier: Programming Language :: Python :: 3.8
-Classifier: Programming Language :: Python :: 3.9
 Classifier: Programming Language :: Python :: 3.10
 Classifier: Programming Language :: Python :: 3.11
 Classifier: Programming Language :: Python :: 3.12
-Requires-Python: >=3.8
+Classifier: Programming Language :: Python :: 3.13
+Classifier: Programming Language :: Python :: 3.14
+Requires-Python: >=3.10
 Requires-Dist: jsonschema>=4.18.0
 Requires-Dist: jupyter-events>=0.11.0
 Requires-Dist: jupyter-server-fileid<1,>=0.7.0
 Requires-Dist: jupyter-server<3.0.0,>=2.15.0
-Requires-Dist: jupyter-ydoc<4.0.0,>=3.0.3
+Requires-Dist: jupyter-ydoc<4.0.0,>=3.4.0
 Requires-Dist: pycrdt
 Requires-Dist: pycrdt-websocket<0.17.0,>=0.16.0
 Provides-Extra: test
 Requires-Dist: anyio; extra == 'test'
 Requires-Dist: coverage; extra == 'test'
 Requires-Dist: dirty-equals; extra == 'test'
-Requires-Dist: httpx-ws>=0.5.2; extra == 'test'
-Requires-Dist: importlib-metadata>=4.8.3; (python_version < '3.10') and extra 
== 'test'
+Requires-Dist: httpx-ws>=0.9.0; extra == 'test'
 Requires-Dist: jupyter-server-fileid[test]; extra == 'test'
 Requires-Dist: jupyter-server[test]>=2.15.0; extra == 'test'
 Requires-Dist: pytest-cov; extra == 'test'
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/jupyter_server_ydoc-2.2.1/jupyter_server_ydoc/__init__.py 
new/jupyter_server_ydoc-2.4.0/jupyter_server_ydoc/__init__.py
--- old/jupyter_server_ydoc-2.2.1/jupyter_server_ydoc/__init__.py       
2020-02-02 01:00:00.000000000 +0100
+++ new/jupyter_server_ydoc-2.4.0/jupyter_server_ydoc/__init__.py       
2020-02-02 01:00:00.000000000 +0100
@@ -1,11 +1,11 @@
 # Copyright (c) Jupyter Development Team.
 # Distributed under the terms of the Modified BSD License.
 
-from typing import Any, Dict, List
+from typing import Any
 
 from ._version import __version__  # noqa
 from .app import YDocExtension
 
 
-def _jupyter_server_extension_points() -> List[Dict[str, Any]]:
+def _jupyter_server_extension_points() -> list[dict[str, Any]]:
     return [{"module": "jupyter_server_ydoc", "app": YDocExtension}]
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/jupyter_server_ydoc-2.2.1/jupyter_server_ydoc/_version.py 
new/jupyter_server_ydoc-2.4.0/jupyter_server_ydoc/_version.py
--- old/jupyter_server_ydoc-2.2.1/jupyter_server_ydoc/_version.py       
2020-02-02 01:00:00.000000000 +0100
+++ new/jupyter_server_ydoc-2.4.0/jupyter_server_ydoc/_version.py       
2020-02-02 01:00:00.000000000 +0100
@@ -1 +1 @@
-__version__ = "2.2.1"
+__version__ = "2.4.0"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/jupyter_server_ydoc-2.2.1/jupyter_server_ydoc/app.py 
new/jupyter_server_ydoc-2.4.0/jupyter_server_ydoc/app.py
--- old/jupyter_server_ydoc-2.2.1/jupyter_server_ydoc/app.py    2020-02-02 
01:00:00.000000000 +0100
+++ new/jupyter_server_ydoc-2.4.0/jupyter_server_ydoc/app.py    2020-02-02 
01:00:00.000000000 +0100
@@ -3,8 +3,9 @@
 from __future__ import annotations
 
 import asyncio
+from collections import defaultdict
 from functools import partial
-from typing import Literal
+from typing import Literal, cast
 
 from jupyter_server.extension.application import ExtensionApp
 from jupyter_ydoc import ydocs as YDOCS
@@ -27,6 +28,7 @@
     AWARENESS_EVENTS_SCHEMA_PATH,
     EVENTS_SCHEMA_PATH,
     FORK_EVENTS_SCHEMA_PATH,
+    decode_file_path,
     encode_file_path,
     room_id_from_encoded_path,
 )
@@ -91,6 +93,8 @@
         model.""",
     )
 
+    _room_locks: dict[str, asyncio.Lock] = defaultdict(asyncio.Lock)
+
     def initialize(self):
         super().initialize()
         self.serverapp.event_logger.register_event_schema(EVENTS_SCHEMA_PATH)
@@ -153,6 +157,7 @@
                         "file_loaders": self.file_loaders,
                         "ystore_class": ystore_class,
                         "ywebsocket_server": self.ywebsocket_server,
+                        "room_locks": self._room_locks,
                     },
                 ),
                 (r"/api/collaboration/session/(.*)", DocSessionHandler),
@@ -182,6 +187,7 @@
         file_format: Literal["json", "text"] | None = None,
         room_id: str | None = None,
         copy: bool = True,
+        create: bool = False,
     ) -> YBaseDoc | None:
         """Get a view of the shared model for the matching document.
 
@@ -190,8 +196,13 @@
 
         If `copy=True`, the returned shared model is a fork, meaning that any 
changes
          made to it will not be propagated to the shared model used by the 
application.
+
+        If `create=True`, the room will be created if it doesn't exist.
         """
-        error_msg = "You need to provide either a ``room_id`` or the ``path``, 
the ``content_type`` and the ``file_format``."
+        error_msg = (
+            "You need to provide either a ``room_id`` or the ``path``, "
+            "the ``content_type`` and the ``file_format``."
+        )
         if room_id is None:
             if path is None or content_type is None or file_format is None:
                 raise ValueError(error_msg)
@@ -204,13 +215,55 @@
 
         elif path is not None or content_type is not None or file_format is 
not None:
             raise ValueError(error_msg)
-        else:
-            room_id = room_id
 
-        try:
-            room = await self.ywebsocket_server.get_room(room_id)
-        except RoomNotFound:
-            return None
+        async with self._room_locks[room_id]:
+            try:
+                room = await self.ywebsocket_server.get_room(room_id)
+            except RoomNotFound:
+                if not create:
+                    return None
+
+                if not self.ywebsocket_server.started.is_set():
+                    asyncio.create_task(self.ywebsocket_server.start())
+                    await self.ywebsocket_server.started.wait()
+
+                file_format_str, file_type, file_id = decode_file_path(room_id)
+                # cast down so mypy won’t complain when we pass this into 
DocumentRoom
+                file_format = cast(Literal["json", "text"], file_format_str)
+                updates_file_path = f".{file_type}:{file_id}.y"
+                ystore = self.ystore_class(
+                    path=updates_file_path,
+                    log=self.log,
+                )
+                # Create a new room
+                room = DocumentRoom(
+                    room_id,
+                    file_format,
+                    file_type,
+                    self.file_loaders[file_id],
+                    self.serverapp.event_logger,
+                    ystore,
+                    self.log,
+                    exception_handler=exception_logger,
+                    save_delay=self.document_save_delay,
+                )
+                await room.initialize()
+                try:
+                    await self.ywebsocket_server.start_room(room)
+                    self.ywebsocket_server.add_room(room_id, room)
+                    self.log.info(f"Created and started room: {room_id}")
+                except Exception as e:
+                    self.log.error("Room %s failed to start on websocket 
server", room_id)
+                    # Clean room
+                    await room.stop()
+                    self.log.info("Room %s deleted", room_id)
+                    file = self.file_loaders[file_id]
+                    if file.number_of_subscriptions == 0 or (
+                        file.number_of_subscriptions == 1 and room_id in 
file._subscriptions
+                    ):
+                        self.log.info("Deleting file %s", file.path)
+                        await self.file_loaders.remove(file_id)
+                    raise e
 
         if isinstance(room, DocumentRoom):
             if copy:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/jupyter_server_ydoc-2.2.1/jupyter_server_ydoc/handlers.py 
new/jupyter_server_ydoc-2.4.0/jupyter_server_ydoc/handlers.py
--- old/jupyter_server_ydoc-2.2.1/jupyter_server_ydoc/handlers.py       
2020-02-02 01:00:00.000000000 +0100
+++ new/jupyter_server_ydoc-2.4.0/jupyter_server_ydoc/handlers.py       
2020-02-02 01:00:00.000000000 +0100
@@ -5,17 +5,16 @@
 
 import asyncio
 import json
-import uuid
+import os
 from logging import Logger
-from typing import Any
+from typing import Any, cast
 from uuid import uuid4
-from typing import cast
 
 from jupyter_server.auth import authorized
 from jupyter_server.base.handlers import APIHandler, JupyterHandler
 from jupyter_server.utils import ensure_async
 from jupyter_ydoc import ydocs as YDOCS
-from pycrdt import Doc, Encoder, UndoManager
+from pycrdt import Decoder, Doc, Encoder, UndoManager
 from pycrdt.store import BaseYStore
 from pycrdt.websocket import YRoom
 from tornado import web
@@ -27,19 +26,21 @@
     JUPYTER_COLLABORATION_AWARENESS_EVENTS_URI,
     JUPYTER_COLLABORATION_EVENTS_URI,
     JUPYTER_COLLABORATION_FORK_EVENTS_URI,
+    SERVER_SESSION,
+    YDOC_SERVER_VERSION,
     LogLevel,
+    MessageType,
+    check_session_compatibility,
     decode_file_path,
     encode_file_path,
     room_id_from_encoded_path,
+    save_current_session,
 )
 from .websocketserver import JupyterWebsocketServer, RoomNotFound
-from .utils import MessageType
-from pycrdt import Decoder
 
 YFILE = YDOCS["file"]
 
 
-SERVER_SESSION = str(uuid.uuid4())
 FORK_DOCUMENTS = {}
 FORK_ROOMS: dict[str, dict[str, str]] = {}
 
@@ -66,7 +67,7 @@
 
     _message_queue: asyncio.Queue[Any]
     _background_tasks: set[asyncio.Task]
-    _room_locks: dict[str, asyncio.Lock] = {}
+    _session_file_lock = asyncio.Lock()
 
     def _room_lock(self, room_id: str) -> asyncio.Lock:
         if room_id not in self._room_locks:
@@ -113,7 +114,11 @@
                         self._emit(
                             LogLevel.WARNING,
                             None,
-                            "There is another collaborative session accessing 
the same file.\nThe synchronization between rooms is not supported and you 
might lose some of your changes.",
+                            (
+                                "There is another collaborative session 
accessing the same "
+                                "file.\nThe synchronization between rooms is 
not supported "
+                                "and you might lose some of your changes."
+                            ),
                         )
 
                     file = self._file_loaders[file_id]
@@ -173,6 +178,7 @@
         ywebsocket_server: JupyterWebsocketServer,
         file_loaders: FileLoaderMapping,
         ystore_class: type[BaseYStore],
+        room_locks: dict[str, asyncio.Lock] | None = None,
         document_cleanup_delay: float | None = 60.0,
         document_save_delay: float | None = 1.0,
     ) -> None:
@@ -187,6 +193,7 @@
         self._message_queue = asyncio.Queue()
         self._room_id = ""
         self.room = None  # type:ignore
+        self._room_locks = room_locks if room_locks is not None else {}
 
     @property
     def path(self):
@@ -232,11 +239,35 @@
         if isinstance(self.room, DocumentRoom):
             # Close the connection if the document session expired
             session_id = self.get_query_argument("sessionId", "")
+            root_dir = self.settings.get("server_root_dir", os.getcwd())
+            document_version = getattr(self.room._document, "version", None)
+
+            # Persist the current session so future reconnects can validate it
+            await save_current_session(
+                root_dir,
+                SERVER_SESSION,
+                YDOC_SERVER_VERSION,
+                self._session_file_lock,
+                document_version=document_version,
+            )
             if SERVER_SESSION != session_id:
-                self.close(
-                    1003,
-                    f"Document session {session_id} expired. You need to 
reload this browser tab.",
+                cannot_reconnect, reason = check_session_compatibility(
+                    root_dir,
+                    session_id,
+                    YDOC_SERVER_VERSION,
+                    current_document_version=document_version,
                 )
+                if cannot_reconnect:
+                    # Must ask the user to reload
+                    close_payload = json.dumps(
+                        {
+                            "reason": reason,
+                            "sessionId": session_id,
+                            "reloadable": True,
+                        }
+                    )
+                    self.close(1003, close_payload)
+                # Else accept the old session, no reload needed.
 
             # cancel the deletion of the room if it was scheduled
             if self.room.cleaner is not None:
@@ -253,13 +284,36 @@
 
                 # Close websocket and propagate error.
                 if isinstance(e, web.HTTPError):
-                    self.log.error(f"File {file.path} not found.\n{e!r}", 
exc_info=e)
-                    self.close(1004, f"File {file.path} not found.")
+                    if e.status_code == 404:
+                        error_code = 4404  # custom code for "file not found"
+                        self.log.error(f"File {file.path} not found.\n{e!r}", 
exc_info=e)
+                    elif e.status_code == 400:
+                        error_code = 4400  # custom code for "bad request"
+                        self.log.error(f"Bad request for file 
{file.path}.\n{e!r}", exc_info=e)
+                    elif e.status_code == 500:
+                        error_code = 4500  # custom code for "internal server 
error"
+                        self.log.error(
+                            f"Internal server error for file 
{file.path}.\n{e!r}", exc_info=e
+                        )
+                    else:
+                        error_code = 4500  # generic error code for other HTTP 
errors
+                        self.log.error(
+                            f"Error initializing room for file 
{file.path}.\n{e!r}", exc_info=e
+                        )
+                    self.close(
+                        error_code,
+                        f"Error initializing: {file.path}.",
+                    )
                 else:
                     self.log.error(f"Error initializing: {file.path}\n{e!r}", 
exc_info=e)
                     self.close(
                         1003,
-                        f"Error initializing: {file.path}. You need to close 
the document.",
+                        json.dumps(
+                            {
+                                "reason": "initialization_error",
+                                "reloadable": False,
+                            }
+                        ),
                     )
 
                 # Clean up the room and delete the file loader
@@ -407,7 +461,8 @@
         Update the users when the global awareness changes.
 
             Parameters:
-                topic (str): `"update"` or `"change"` (`"change"` is triggered 
only if the states are modified).
+                topic (str): `"update"` or `"change"` (`"change"` is triggered
+                    only if the states are modified).
                 changes (tuple[dict[str, Any], Any]): The changes and the 
origin of the changes.
         """
         if topic != "change":
@@ -440,8 +495,8 @@
     auth_resource = "contents"
 
     @web.authenticated
-    @authorized
-    async def put(self, path):
+    @authorized  # type: ignore[misc]
+    async def put(self, path: str) -> asyncio.Future[Any]:
         """
         Creates a new session for a given document or returns an existing one.
         """
@@ -453,7 +508,11 @@
         idx = file_id_manager.get_id(path)
         if idx is not None:
             # index already exists
-            self.log.info("Request for Y document '%s' with room ID: %s", 
path, idx)
+            self.log.info(
+                "Request for Y document (previously indexed) '%s' with room 
ID: %s",
+                path,
+                idx,
+            )
             data = json.dumps(
                 {
                     "format": format,
@@ -472,7 +531,7 @@
             raise web.HTTPError(404, f"File {path!r} does not exist")
 
         # index successfully created
-        self.log.info("Request for Y document '%s' with room ID: %s", path, 
idx)
+        self.log.info("Request for Y document (now indexed) '%s' with room ID: 
%s", path, idx)
         data = json.dumps(
             {
                 "format": format,
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/jupyter_server_ydoc-2.2.1/jupyter_server_ydoc/loaders.py 
new/jupyter_server_ydoc-2.4.0/jupyter_server_ydoc/loaders.py
--- old/jupyter_server_ydoc-2.2.1/jupyter_server_ydoc/loaders.py        
2020-02-02 01:00:00.000000000 +0100
+++ new/jupyter_server_ydoc-2.4.0/jupyter_server_ydoc/loaders.py        
2020-02-02 01:00:00.000000000 +0100
@@ -4,11 +4,11 @@
 from __future__ import annotations
 
 import asyncio
+from collections.abc import Callable, Coroutine
+from http import HTTPStatus
 from logging import Logger, getLogger
 from time import time
-from typing import Any, Callable, Coroutine
-from tornado.web import HTTPError
-from http import HTTPStatus
+from typing import Any
 
 from jupyter_server.services.contents.manager import (
     AsyncContentsManager,
@@ -16,6 +16,7 @@
 )
 from jupyter_server.utils import ensure_async
 from jupyter_server_fileid.manager import BaseFileIdManager
+from tornado.web import HTTPError
 
 from .utils import OutOfBandChanges
 
@@ -148,7 +149,8 @@
         Save the content of the file.
 
             Parameters:
-                model (dict): A dictionary with format, type, last_modified, 
and content of the file.
+                model (dict): A dictionary with format, type, last_modified,
+                    and content of the file.
 
             Raises:
                 OutOfBandChanges: if the file was modified at a latter time 
than the model
@@ -246,7 +248,10 @@
                             errors_duration = time() - 
consecutive_errors_started
                             if errors_duration > 
self._stop_poll_on_errors_after:
                                 self._log.warning(
-                                    "Stopping watching file due to consecutive 
errors over %s seconds: %s",
+                                    (
+                                        "Stopping watching file due to 
consecutive "
+                                        "errors over %s seconds: %s"
+                                    ),
                                     self._stop_poll_on_errors_after,
                                     self.path,
                                 )
@@ -313,7 +318,8 @@
         Args:
             settings: Server settings
             log: [optional] Server log; default to local logger
-            file_poll_interval: [optional] Interval between room notification; 
default the loader won't poll
+            file_poll_interval: [optional] Interval between room
+                notification; default the loader won't poll
         """
         self._settings = settings
         self.__dict: dict[str, FileLoader] = {}
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/jupyter_server_ydoc-2.2.1/jupyter_server_ydoc/pytest_plugin.py 
new/jupyter_server_ydoc-2.4.0/jupyter_server_ydoc/pytest_plugin.py
--- old/jupyter_server_ydoc-2.2.1/jupyter_server_ydoc/pytest_plugin.py  
2020-02-02 01:00:00.000000000 +0100
+++ new/jupyter_server_ydoc-2.4.0/jupyter_server_ydoc/pytest_plugin.py  
2020-02-02 01:00:00.000000000 +0100
@@ -10,13 +10,14 @@
 import nbformat
 import pytest
 from httpx_ws import aconnect_ws
-from jupyter_server_ydoc.loaders import FileLoader
-from jupyter_server_ydoc.rooms import DocumentRoom
-from jupyter_server_ydoc.stores import SQLiteYStore
 from jupyter_ydoc import YNotebook, YUnicode
 from pycrdt import Provider
 from pycrdt.websocket.websocket import HttpxWebsocket
 
+from jupyter_server_ydoc.loaders import FileLoader
+from jupyter_server_ydoc.rooms import DocumentRoom
+from jupyter_server_ydoc.stores import SQLiteYStore
+
 from .test_utils import (
     FakeContentsManager,
     FakeEventLogger,
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/jupyter_server_ydoc-2.2.1/jupyter_server_ydoc/rooms.py 
new/jupyter_server_ydoc-2.4.0/jupyter_server_ydoc/rooms.py
--- old/jupyter_server_ydoc-2.2.1/jupyter_server_ydoc/rooms.py  2020-02-02 
01:00:00.000000000 +0100
+++ new/jupyter_server_ydoc-2.4.0/jupyter_server_ydoc/rooms.py  2020-02-02 
01:00:00.000000000 +0100
@@ -4,13 +4,15 @@
 from __future__ import annotations
 
 import asyncio
+from collections.abc import Callable
 from logging import Logger
-from typing import Any, Callable
+from typing import Any
 
 from jupyter_events import EventLogger
 from jupyter_ydoc import ydocs as YDOCS
-from pycrdt.websocket import YRoom
+from pycrdt import Doc
 from pycrdt.store import BaseYStore, YDocNotFound
+from pycrdt.websocket import YRoom
 
 from .loaders import FileLoader
 from .utils import JUPYTER_COLLABORATION_EVENTS_URI, LogLevel, OutOfBandChanges
@@ -113,6 +115,7 @@
         async with self._update_lock:
             # try to apply Y updates from the YStore for this document
             read_from_source = True
+            loaded_from_store = False
             if self.ystore is not None:
                 async with self.ystore.start_lock:
                     if not self.ystore.started.is_set():
@@ -123,9 +126,7 @@
                     self._emit(
                         LogLevel.INFO,
                         "load",
-                        "Content loaded from the store {}".format(
-                            self.ystore.__class__.__qualname__
-                        ),
+                        f"Content loaded from the store 
{self.ystore.__class__.__qualname__}",
                     )
                     self.log.info(
                         "Content in room %s loaded from the ystore %s",
@@ -133,13 +134,15 @@
                         self.ystore.__class__.__name__,
                     )
                     read_from_source = False
+                    loaded_from_store = True
                 except YDocNotFound:
-                    # YDoc not found in the YStore, create the document from 
the source file (no change history)
+                    # YDoc not found in the YStore, create the document from
+                    # the source file (no change history)
                     pass
 
             if not read_from_source:
                 # if YStore updates and source file are out-of-sync, resync 
updates with source
-                if self._document.source != model["content"]:
+                if await self._document.aget() != model["content"]:
                     # TODO: Delete document from the store.
                     self._emit(
                         LogLevel.INFO,
@@ -160,7 +163,10 @@
                     self._room_id,
                     self._file.path,
                 )
-                self._document.source = model["content"]
+                if not loaded_from_store:
+                    await 
self._apply_deterministic_source_content(model["content"])
+                else:
+                    await self._document.aset(model["content"])
 
                 if self.ystore:
                     await self.ystore.encode_state_as_update(self.ydoc)
@@ -169,6 +175,22 @@
             self.ready = True
             self._emit(LogLevel.INFO, "initialize", "Room initialized")
 
+    async def _apply_deterministic_source_content(self, content: Any) -> None:
+        """Load source content using a deterministic update.
+
+        Rooms rebuilt from disk must recreate the same Yjs history for 
identical
+        content, otherwise reconnecting clients can merge duplicate content 
from a
+        divergent history after server restart or room eviction.
+
+        The client ID needs to be fixed to a deterministic value, see:
+        
https://discuss.yjs.dev/t/initial-offline-value-of-a-shared-document/465
+        """
+
+        source_ydoc: Doc = Doc(client_id=0)
+        source_document = YDOCS.get(self._file_type, YFILE)(source_ydoc)
+        await source_document.aset(content)
+        self.ydoc.apply_update(source_ydoc.get_update())
+
     def _emit(self, level: LogLevel, action: str | None = None, msg: str | 
None = None) -> None:
         data = {"level": level.value, "room": self._room_id, "path": 
self._file.path}
         if action:
@@ -223,7 +245,8 @@
             return
 
         async with self._update_lock:
-            self._document.source = model["content"]
+            if await self._document.aget() != model["content"]:
+                await self._document.aset(model["content"])
             self._document.dirty = False
 
     def _on_filepath_change(self) -> None:
@@ -319,7 +342,7 @@
                 {
                     "format": self._file_format,
                     "type": self._file_type,
-                    "content": self._document.source,
+                    "content": await self._document.aget(),
                 }
             )
             if saved_model:
@@ -343,7 +366,8 @@
                 return None
 
             async with self._update_lock:
-                self._document.source = model["content"]
+                if await self._document.aget() != model["content"]:
+                    await self._document.aset(model["content"])
                 self._document.dirty = False
 
             self._emit(LogLevel.INFO, "overwrite", "Out-of-band changes while 
saving.")
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/jupyter_server_ydoc-2.2.1/jupyter_server_ydoc/stores.py 
new/jupyter_server_ydoc-2.4.0/jupyter_server_ydoc/stores.py
--- old/jupyter_server_ydoc-2.2.1/jupyter_server_ydoc/stores.py 2020-02-02 
01:00:00.000000000 +0100
+++ new/jupyter_server_ydoc-2.4.0/jupyter_server_ydoc/stores.py 2020-02-02 
01:00:00.000000000 +0100
@@ -38,6 +38,7 @@
         None,
         allow_none=True,
         config=True,
-        help="""The document time-to-live in seconds. Deprecated in favor of 
'squash_after_inactivity_of'.
+        help="""The document time-to-live in seconds.
+        Deprecated in favor of 'squash_after_inactivity_of'.
         Defaults to None (document history is never cleared).""",
     )
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/jupyter_server_ydoc-2.2.1/jupyter_server_ydoc/test_utils.py 
new/jupyter_server_ydoc-2.4.0/jupyter_server_ydoc/test_utils.py
--- old/jupyter_server_ydoc-2.2.1/jupyter_server_ydoc/test_utils.py     
2020-02-02 01:00:00.000000000 +0100
+++ new/jupyter_server_ydoc-2.4.0/jupyter_server_ydoc/test_utils.py     
2020-02-02 01:00:00.000000000 +0100
@@ -5,9 +5,9 @@
 
 from datetime import datetime
 from typing import Any
-from tornado.web import HTTPError
 
 from jupyter_server import _tz as tz
+from tornado.web import HTTPError
 
 
 class FakeFileIDManager:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/jupyter_server_ydoc-2.2.1/jupyter_server_ydoc/utils.py 
new/jupyter_server_ydoc-2.4.0/jupyter_server_ydoc/utils.py
--- old/jupyter_server_ydoc-2.2.1/jupyter_server_ydoc/utils.py  2020-02-02 
01:00:00.000000000 +0100
+++ new/jupyter_server_ydoc-2.4.0/jupyter_server_ydoc/utils.py  2020-02-02 
01:00:00.000000000 +0100
@@ -1,9 +1,14 @@
 # Copyright (c) Jupyter Development Team.
 # Distributed under the terms of the Modified BSD License.
-
+import asyncio
+import json
+import os
+import uuid
+from datetime import datetime, timezone
 from enum import Enum, IntEnum
 from pathlib import Path
-from typing import Tuple
+
+from ._version import __version__  # noqa
 
 EVENTS_FOLDER_PATH = Path(__file__).parent / "events"
 JUPYTER_COLLABORATION_EVENTS_URI = 
"https://schema.jupyter.org/jupyter_collaboration/session/v1";
@@ -14,6 +19,8 @@
 JUPYTER_COLLABORATION_FORK_EVENTS_URI = 
"https://schema.jupyter.org/jupyter_collaboration/fork/v1";
 AWARENESS_EVENTS_SCHEMA_PATH = EVENTS_FOLDER_PATH / "awareness.yaml"
 FORK_EVENTS_SCHEMA_PATH = EVENTS_FOLDER_PATH / "fork.yaml"
+SERVER_SESSION = str(uuid.uuid4())
+YDOC_SERVER_VERSION = __version__
 
 
 class MessageType(IntEnum):
@@ -43,7 +50,7 @@
     pass
 
 
-def decode_file_path(path: str) -> Tuple[str, str, str]:
+def decode_file_path(path: str) -> tuple[str, str, str]:
     """
     Decodes a file path. The file path is composed by the format,
     content type, and path or file id separated by ':'.
@@ -52,7 +59,7 @@
             path (str): File path.
 
         Returns:
-            components (Tuple[str, str, str]): A tuple with the format,
+            components (tuple[str, str, str]): A tuple with the format,
                 content type, and path or file id.
     """
     format, file_type, file_id = path.split(":", 2)
@@ -78,3 +85,106 @@
 def room_id_from_encoded_path(encoded_path: str) -> str:
     """Transforms the encoded path into a stable room identifier."""
     return encoded_path.split("/")[-1]
+
+
+def _get_jupyter_session_store(root_dir: str) -> Path:
+    """Return path to the session store file in .jupyter folder."""
+    try:
+        expanded = Path(root_dir).expanduser()
+        resolved = expanded.resolve()
+        jupyter_dir = resolved / ".jupyter"
+        jupyter_dir.mkdir(parents=True, exist_ok=True)
+        return jupyter_dir / "collaboration_sessions.json"
+    except OSError:
+        # In case if the server root dir is read-only
+        return Path(os.devnull)
+
+
+def _load_previous_sessions(root_dir: str) -> dict:
+    """Load previous session records from .jupyter folder."""
+    store_path = _get_jupyter_session_store(root_dir)
+    if store_path.exists():
+        try:
+            sessions = json.loads(store_path.read_text())
+            # Ensure the loaded JSON is a dict mapping session IDs to dicts.
+            if not isinstance(sessions, dict):
+                return {}
+            clean_sessions = {}
+            for key, value in sessions.items():
+                if isinstance(value, dict):
+                    clean_sessions[str(key)] = value
+            return clean_sessions
+        except (OSError, json.JSONDecodeError, UnicodeDecodeError):
+            return {}
+    return {}
+
+
+async def save_current_session(
+    root_dir: str,
+    session_id: str,
+    version: str,
+    lock: asyncio.Lock,
+    document_version: str | None = None,
+) -> None:
+    """Persist the current session ID, server version, and optionally
+    document version to .jupyter folder."""
+    async with lock:
+        store_path = _get_jupyter_session_store(root_dir)
+        sessions = _load_previous_sessions(root_dir)
+
+        sessions[session_id] = {
+            "version": version,
+            "created_at": datetime.now(timezone.utc).isoformat(),
+        }
+        if document_version is not None:
+            sessions[session_id]["document_version"] = document_version
+
+        # Keep only the last 10 sessions to avoid unbounded growth
+        if len(sessions) > 10:
+            oldest_key = sorted(sessions, key=lambda k: 
sessions[k].get("created_at", ""))[0]
+            del sessions[oldest_key]
+        try:
+            store_path.write_text(json.dumps(sessions, indent=2))
+        except OSError:
+            pass
+
+
+def check_session_compatibility(
+    root_dir: str,
+    client_session_id: str,
+    current_version: str,
+    current_document_version: str | None = None,
+) -> tuple[bool, str]:
+    """
+    Determine whether a client carrying an old session ID can reconnect or not.
+
+    Returns:
+        (cannot_reconnect: bool, reason: str)
+    """
+    if client_session_id == SERVER_SESSION:
+        return False, ""
+
+    previous_sessions = _load_previous_sessions(root_dir)
+
+    # Session ID not in our records at all → unknown origin, reject
+    if client_session_id not in previous_sessions:
+        return True, "unknown_session"
+
+    previous = previous_sessions[client_session_id]
+    previous_version = previous.get("version", "")
+
+    # Collaboration package version changed → reject
+    if previous_version != current_version:
+        return True, "version_mismatch"
+
+    # Check document version if both old and new sessions have it
+    if current_document_version is not None:
+        previous_document_version = previous.get("document_version")
+        if (
+            previous_document_version is not None
+            and previous_document_version != current_document_version
+        ):
+            return True, "version_mismatch"
+
+    # Same directory + same versions → safe to reconnect
+    return False, ""
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/jupyter_server_ydoc-2.2.1/jupyter_server_ydoc/websocketserver.py 
new/jupyter_server_ydoc-2.4.0/jupyter_server_ydoc/websocketserver.py
--- old/jupyter_server_ydoc-2.2.1/jupyter_server_ydoc/websocketserver.py        
2020-02-02 01:00:00.000000000 +0100
+++ new/jupyter_server_ydoc-2.4.0/jupyter_server_ydoc/websocketserver.py        
2020-02-02 01:00:00.000000000 +0100
@@ -4,8 +4,9 @@
 from __future__ import annotations
 
 import asyncio
+from collections.abc import Callable
 from logging import Logger
-from typing import Any, Callable
+from typing import Any
 
 from pycrdt import Channel
 from pycrdt.store import BaseYStore
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/jupyter_server_ydoc-2.2.1/pyproject.toml 
new/jupyter_server_ydoc-2.4.0/pyproject.toml
--- old/jupyter_server_ydoc-2.2.1/pyproject.toml        2020-02-02 
01:00:00.000000000 +0100
+++ new/jupyter_server_ydoc-2.4.0/pyproject.toml        2020-02-02 
01:00:00.000000000 +0100
@@ -9,7 +9,7 @@
 name = "jupyter-server-ydoc"
 readme = "README.md"
 license = { file = "LICENSE" }
-requires-python = ">=3.8"
+requires-python = ">=3.10"
 description = "jupyter-server extension integrating collaborative shared 
models."
 classifiers = [
     "Intended Audience :: Developers",
@@ -17,11 +17,11 @@
     "Intended Audience :: Science/Research",
     "License :: OSI Approved :: BSD License",
     "Programming Language :: Python",
-    "Programming Language :: Python :: 3.8",
-    "Programming Language :: Python :: 3.9",
     "Programming Language :: Python :: 3.10",
     "Programming Language :: Python :: 3.11",
     "Programming Language :: Python :: 3.12",
+    "Programming Language :: Python :: 3.13",
+    "Programming Language :: Python :: 3.14",
     "Framework :: Jupyter"
 ]
 authors = [
@@ -29,7 +29,7 @@
 ]
 dependencies = [
     "jupyter_server>=2.15.0,<3.0.0",
-    "jupyter_ydoc>=3.0.3,<4.0.0",
+    "jupyter_ydoc>=3.4.0,<4.0.0",
     "pycrdt",
     "pycrdt-websocket>=0.16.0,<0.17.0",
     "jupyter_events>=0.11.0",
@@ -54,8 +54,7 @@
     "pytest>=7.0",
     "pytest-cov",
     "anyio",
-    "httpx-ws >=0.5.2",
-    "importlib_metadata >=4.8.3; python_version<'3.10'",
+    "httpx-ws >=0.9.0",
 ]
 
 [tool.hatch.version]
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/jupyter_server_ydoc-2.2.1/tests/test_app.py 
new/jupyter_server_ydoc-2.4.0/tests/test_app.py
--- old/jupyter_server_ydoc-2.2.1/tests/test_app.py     2020-02-02 
01:00:00.000000000 +0100
+++ new/jupyter_server_ydoc-2.4.0/tests/test_app.py     2020-02-02 
01:00:00.000000000 +0100
@@ -104,6 +104,24 @@
     await collaboration.stop_extension()
 
 
+async def test_get_document_create_room(rtc_create_file, jp_serverapp):
+    path, content = await rtc_create_file("test.txt", "test", index=True)
+    collaboration = jp_serverapp.web_app.settings["jupyter_server_ydoc"]
+    # Document doesn't exist initially
+    document_before = await collaboration.get_document(
+        path=path, content_type="file", file_format="text", create=False
+    )
+    assert document_before is None
+
+    document_after = await collaboration.get_document(
+        path=path, content_type="file", file_format="text", create=True
+    )
+    # Verify document was created and has correct content
+    assert document_after is not None
+    assert document_after.get() == content == "test"
+    await collaboration.stop_extension()
+
+
 @pytest.mark.parametrize("copy", [True, False])
 async def test_get_document_notebook(rtc_create_notebook, jp_serverapp, copy):
     nb = nbformat.v4.new_notebook(
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/jupyter_server_ydoc-2.2.1/tests/test_documents.py 
new/jupyter_server_ydoc-2.4.0/tests/test_documents.py
--- old/jupyter_server_ydoc-2.2.1/tests/test_documents.py       2020-02-02 
01:00:00.000000000 +0100
+++ new/jupyter_server_ydoc-2.4.0/tests/test_documents.py       2020-02-02 
01:00:00.000000000 +0100
@@ -1,16 +1,16 @@
 # Copyright (c) Jupyter Development Team.
 # Distributed under the terms of the Modified BSD License.
 
-import sys
+from copy import deepcopy
+from importlib.metadata import entry_points
 from time import time
 
-if sys.version_info < (3, 10):
-    from importlib_metadata import entry_points
-else:
-    from importlib.metadata import entry_points
-
 import pytest
 from anyio import create_task_group, sleep
+from jupyter_server_ydoc.loaders import FileLoader
+from jupyter_server_ydoc.rooms import DocumentRoom
+from jupyter_server_ydoc.test_utils import FakeContentsManager, 
FakeEventLogger, FakeFileIDManager
+from jupyter_ydoc import YNotebook
 from pycrdt import Provider
 from pycrdt.websocket.websocket import HttpxWebsocket
 
@@ -99,3 +99,73 @@
     assert dt < 1
 
     await cleanup(jp_serverapp)
+
+
+def _notebook_model() -> dict:
+    return {
+        "nbformat": 4,
+        "nbformat_minor": 5,
+        "metadata": {},
+        "cells": [
+            {
+                "cell_type": "code",
+                "id": "cell-1",
+                "metadata": {},
+                "source": "",
+                "outputs": [],
+                "execution_count": None,
+            }
+        ],
+    }
+
+
+async def _create_notebook_room(notebook: dict, room_id: str) -> 
tuple[DocumentRoom, FileLoader]:
+    file_id = f"file-{room_id}"
+    loader = FileLoader(
+        file_id,
+        FakeFileIDManager({file_id: "test.ipynb"}),
+        FakeContentsManager({"content": deepcopy(notebook), "writable": True}),
+    )
+    room = DocumentRoom(
+        room_id,
+        "json",
+        "notebook",
+        loader,
+        FakeEventLogger(),
+        None,
+        None,
+        None,
+    )
+    await room.initialize()
+    return room, loader
+
+
+def _sync_documents(client_doc: YNotebook, room: DocumentRoom) -> dict:
+    room.ydoc.apply_update(client_doc.ydoc.get_update())
+    client_doc.ydoc.apply_update(room.ydoc.get_update())
+    return client_doc.get(deduplicate=False)
+
+
+async def 
test_notebook_reconnect_with_divergent_history_does_not_duplicate_initial_cell():
+    notebook = _notebook_model()
+    room, loader = await _create_notebook_room(notebook, 
"divergent-history-before")
+    client_doc = YNotebook()
+
+    try:
+        # Initial connection: client receives the original server-side history.
+        client_doc.ydoc.apply_update(room.ydoc.get_update())
+    finally:
+        # Simulate the server losing in-memory state while the client keeps 
its local YDoc.
+        await room.stop()
+        await loader.clean()
+
+    recreated_room, recreated_loader = await _create_notebook_room(
+        notebook, "divergent-history-after"
+    )
+    try:
+        merged = _sync_documents(client_doc, recreated_room)
+    finally:
+        await recreated_room.stop()
+        await recreated_loader.clean()
+
+    assert len(merged["cells"]) == 1
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/jupyter_server_ydoc-2.2.1/tests/test_handlers.py 
new/jupyter_server_ydoc-2.4.0/tests/test_handlers.py
--- old/jupyter_server_ydoc-2.2.1/tests/test_handlers.py        2020-02-02 
01:00:00.000000000 +0100
+++ new/jupyter_server_ydoc-2.4.0/tests/test_handlers.py        2020-02-02 
01:00:00.000000000 +0100
@@ -4,14 +4,14 @@
 from __future__ import annotations
 
 import json
-import pytest
 from asyncio import Event, sleep
 from typing import Any
 
+import pytest
 from dirty_equals import IsStr
 from jupyter_events.logger import EventLogger
 from jupyter_ydoc import YUnicode
-from pycrdt import Text, Provider
+from pycrdt import Provider, Text
 from pycrdt.websocket.websocket import HttpxWebsocket
 
 
@@ -119,8 +119,8 @@
     websocket, room_name = await rtc_connect_doc_client("text", "file", path)
     async with websocket as ws, Provider(doc.ydoc, HttpxWebsocket(ws, 
room_name)):
         await event.wait()
-        await sleep(0.1)
 
+    await sleep(0.1)
     fim = jp_serverapp.web_app.settings["file_id_manager"]
 
     assert doc.source == content
@@ -258,7 +258,18 @@
     assert listener_was_called is True
     assert len(collected_data) == 4
     # no two collaboration events are emitted.
-    # [{'level': 'WARNING', 'msg': 'There is another collaborative session 
accessing the same file.\nThe synchronization bet...ou might lose some of your 
changes.', 'path': 'test2.txt', 'room': 
'text2:file2:51b7e24f-f534-47fb-882f-5cc45ba867fe'}]
+    # [
+    #     {
+    #         "level": "WARNING",
+    #         "msg": (
+    #             "There is another collaborative session accessing the same 
file.\n"
+    #             "The synchronization between sessions may fail and you might 
lose "
+    #             "some of your changes."
+    #         ),
+    #         "path": "test2.txt",
+    #         "room": "text2:file2:51b7e24f-f534-47fb-882f-5cc45ba867fe",
+    #     }
+    # ]
     assert collected_data[0]["path"] == "test2.txt"
     assert collected_data[0]["room"] == "text2:file2:" + 
fim.get_id("test2.txt")
     assert collected_data[0]["action"] == "clean"
@@ -381,8 +392,9 @@
         fork_ydoc.observe(_on_fork_change)
         fork_text = fork_ydoc.ydoc.get("source", type=Text)
 
-        async with await rtc_connect_fork_client(fork_roomid1) as ws, Provider(
-            fork_ydoc.ydoc, HttpxWebsocket(ws, fork_roomid1)
+        async with (
+            await rtc_connect_fork_client(fork_roomid1) as ws,
+            Provider(fork_ydoc.ydoc, HttpxWebsocket(ws, fork_roomid1)),
         ):
             await fork_connect_event.wait()
             root_text = root_ydoc.ydoc.get("source", type=Text)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/jupyter_server_ydoc-2.2.1/tests/test_rooms.py 
new/jupyter_server_ydoc-2.4.0/tests/test_rooms.py
--- old/jupyter_server_ydoc-2.2.1/tests/test_rooms.py   2020-02-02 
01:00:00.000000000 +0100
+++ new/jupyter_server_ydoc-2.4.0/tests/test_rooms.py   2020-02-02 
01:00:00.000000000 +0100
@@ -4,7 +4,9 @@
 from __future__ import annotations
 
 import asyncio
+from unittest.mock import AsyncMock, patch
 
+from jupyter_server_ydoc.utils import OutOfBandChanges
 from jupyter_ydoc import YUnicode
 
 
@@ -243,3 +245,83 @@
 #     await asyncio.sleep(0.15)
 
 #     assert room._document.path == new_path
+
+
+async def test_on_outofband_change_skips_aset_when_content_unchanged(
+    rtc_create_mock_document_room,
+):
+    """aset should not be called when out-of-band content matches the 
document."""
+    content = "test"
+    _, _, room = rtc_create_mock_document_room("test-id", "test.txt", content)
+    await room.initialize()
+
+    with patch.object(room._document, "aset", new_callable=AsyncMock) as 
mock_aset:
+        await room._on_outofband_change()
+        mock_aset.assert_not_called()
+
+    assert not room._document.dirty
+
+
+async def test_on_outofband_change_calls_aset_when_content_changed(
+    rtc_create_mock_document_room,
+):
+    """aset should be called when out-of-band content differs from the 
document."""
+    content = "test"
+    cm, _, room = rtc_create_mock_document_room("test-id", "test.txt", content)
+    await room.initialize()
+
+    # Simulate the file changing on disk
+    cm.model["content"] = "new content from disk"
+
+    with patch.object(room._document, "aset", new_callable=AsyncMock) as 
mock_aset:
+        await room._on_outofband_change()
+        mock_aset.assert_called_once_with("new content from disk")
+
+    assert not room._document.dirty
+
+
+async def test_save_oob_skips_aset_when_content_unchanged(
+    rtc_create_mock_document_room,
+):
+    """During save with OutOfBandChanges, aset should be skipped if content 
matches."""
+    content = "test"
+    cm, loader, room = rtc_create_mock_document_room(
+        "test-id", "test.txt", content, save_delay=0.01
+    )
+    await room.initialize()
+
+    with (
+        patch.object(
+            loader, "maybe_save_content", new_callable=AsyncMock, 
side_effect=OutOfBandChanges
+        ),
+        patch.object(room._document, "aset", new_callable=AsyncMock) as 
mock_aset,
+    ):
+        await room._maybe_save_document(None, save_now=True)
+        mock_aset.assert_not_called()
+
+    assert not room._document.dirty
+
+
+async def test_save_oob_calls_aset_when_content_changed(
+    rtc_create_mock_document_room,
+):
+    """During save with OutOfBandChanges, aset should be called if content 
differs."""
+    content = "test"
+    cm, loader, room = rtc_create_mock_document_room(
+        "test-id", "test.txt", content, save_delay=0.01
+    )
+    await room.initialize()
+
+    # Simulate file changing on disk after save attempt
+    cm.model["content"] = "changed on disk"
+
+    with (
+        patch.object(
+            loader, "maybe_save_content", new_callable=AsyncMock, 
side_effect=OutOfBandChanges
+        ),
+        patch.object(room._document, "aset", new_callable=AsyncMock) as 
mock_aset,
+    ):
+        await room._maybe_save_document(None, save_now=True)
+        mock_aset.assert_called_once_with("changed on disk")
+
+    assert not room._document.dirty
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/jupyter_server_ydoc-2.2.1/tests/test_session_store.py 
new/jupyter_server_ydoc-2.4.0/tests/test_session_store.py
--- old/jupyter_server_ydoc-2.2.1/tests/test_session_store.py   1970-01-01 
01:00:00.000000000 +0100
+++ new/jupyter_server_ydoc-2.4.0/tests/test_session_store.py   2020-02-02 
01:00:00.000000000 +0100
@@ -0,0 +1,104 @@
+# Copyright (c) Jupyter Development Team.
+# Distributed under the terms of the Modified BSD License.
+
+import asyncio
+
+from jupyter_server_ydoc.utils import (
+    YDOC_SERVER_VERSION,
+    check_session_compatibility,
+    save_current_session,
+)
+
+
+async def test_allows_reconnect_same_dir_same_version(tmp_path):
+    await save_current_session(str(tmp_path), "old-session", 
YDOC_SERVER_VERSION, asyncio.Lock())
+    cannot_reconnect, reason = check_session_compatibility(
+        str(tmp_path), "old-session", YDOC_SERVER_VERSION
+    )
+    assert cannot_reconnect is False
+    assert reason == ""
+
+
+async def test_rejects_reconnect_version_mismatch(tmp_path):
+    await save_current_session(str(tmp_path), "old-session", "0.0.1", 
asyncio.Lock())
+    cannot_reconnect, reason = check_session_compatibility(
+        str(tmp_path), "old-session", YDOC_SERVER_VERSION
+    )
+    assert cannot_reconnect is True
+    assert reason == "version_mismatch"
+
+
+async def test_rejects_reconnect_different_directory(tmp_path):
+    other_dir = tmp_path / "other"
+    other_dir.mkdir()
+    await save_current_session(str(other_dir), "old-session", 
YDOC_SERVER_VERSION, asyncio.Lock())
+    cannot_reconnect, reason = check_session_compatibility(
+        str(tmp_path), "old-session", YDOC_SERVER_VERSION
+    )
+    assert cannot_reconnect is True
+    # Since it cannot find .jupyter folder or the session ID if folder is 
present
+    assert reason == "unknown_session"
+
+
+def test_rejects_unknown_session(tmp_path):
+    cannot_reconnect, reason = check_session_compatibility(
+        str(tmp_path), "never-seen-session", YDOC_SERVER_VERSION
+    )
+    assert cannot_reconnect is True
+    assert reason == "unknown_session"
+
+
+async def test_allows_reconnect_with_document_version(tmp_path):
+    """Test that document version is saved and validated correctly."""
+    doc_version = "2.1.0"
+    await save_current_session(
+        str(tmp_path),
+        "doc-session",
+        YDOC_SERVER_VERSION,
+        asyncio.Lock(),
+        document_version=doc_version,
+    )
+    cannot_reconnect, reason = check_session_compatibility(
+        str(tmp_path),
+        "doc-session",
+        YDOC_SERVER_VERSION,
+        current_document_version=doc_version,
+    )
+    assert cannot_reconnect is False
+    assert reason == ""
+
+
+async def test_rejects_reconnect_document_version_mismatch(tmp_path):
+    """Test that document version mismatch is detected."""
+    old_doc_version = "2.0.0"
+    new_doc_version = "2.1.0"
+    await save_current_session(
+        str(tmp_path),
+        "doc-session",
+        YDOC_SERVER_VERSION,
+        asyncio.Lock(),
+        document_version=old_doc_version,
+    )
+    cannot_reconnect, reason = check_session_compatibility(
+        str(tmp_path),
+        "doc-session",
+        YDOC_SERVER_VERSION,
+        current_document_version=new_doc_version,
+    )
+    assert cannot_reconnect is True
+    assert reason == "version_mismatch"
+
+
+async def 
test_allows_reconnect_without_document_version_in_old_session(tmp_path):
+    """Test backward compatibility: old sessions without document version are 
still allowed."""
+    # Old session saved without document_version
+    await save_current_session(str(tmp_path), "old-session", 
YDOC_SERVER_VERSION, asyncio.Lock())
+    # New client has document version but old session doesn't → should allow
+    cannot_reconnect, reason = check_session_compatibility(
+        str(tmp_path),
+        "old-session",
+        YDOC_SERVER_VERSION,
+        current_document_version="2.1.0",
+    )
+    assert cannot_reconnect is False
+    assert reason == ""

Reply via email to