This is an automated email from the ASF dual-hosted git repository.

eschutho pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new 940b670636e feat(versioning): version-restore engine and endpoints for 
charts, dashboards, and datasets (#42469)
940b670636e is described below

commit 940b670636e4360423035c1a57322fcdf4d018c3
Author: Mike Bridge <[email protected]>
AuthorDate: Wed Jul 29 17:54:37 2026 +0100

    feat(versioning): version-restore engine and endpoints for charts, 
dashboards, and datasets (#42469)
    
    Co-authored-by: Mike Bridge <[email protected]>
    Co-authored-by: Claude Opus 4.8 <[email protected]>
---
 superset/charts/api.py                             |  68 +++
 superset/commands/chart/restore_version.py         |  44 ++
 superset/commands/dashboard/restore_version.py     |  45 ++
 superset/commands/dataset/restore_version.py       |  41 ++
 superset/commands/version_restore.py               | 152 ++++++
 superset/daos/version.py                           |  18 +-
 superset/dashboards/api.py                         |  72 +++
 superset/datasets/api.py                           |  72 +++
 superset/initialization/__init__.py                |   7 +-
 superset/versioning/api_helpers.py                 |  72 ++-
 superset/versioning/baseline/__init__.py           |   2 +
 superset/versioning/baseline/shadow.py             |   5 +
 superset/versioning/changes/shadow_queries.py      |  11 +-
 superset/versioning/queries.py                     |  43 +-
 superset/versioning/restore.py                     | 264 +++++++++++
 superset/versioning/utils.py                       |  53 +++
 .../charts/version_restore_tests.py                | 456 ++++++++++++++++++
 .../dashboards/version_restore_tests.py            | 418 +++++++++++++++++
 .../datasets/version_restore_tests.py              | 507 +++++++++++++++++++++
 tests/unit_tests/versioning/test_restore.py        | 133 ++++++
 20 files changed, 2455 insertions(+), 28 deletions(-)

diff --git a/superset/charts/api.py b/superset/charts/api.py
index 137c6dfac6e..9ddada7aaf7 100644
--- a/superset/charts/api.py
+++ b/superset/charts/api.py
@@ -111,6 +111,7 @@ from superset.versioning.api_helpers import (
     current_entity_version_info,
     get_version_endpoint,
     list_versions_endpoint,
+    restore_version_endpoint,
 )
 from superset.versioning.etag import set_version_etag
 from superset.versioning.schemas import VersionListItemSchema
@@ -159,6 +160,7 @@ class ChartRestApi(SoftDeleteApiMixin, 
BaseSupersetModelRestApi):
         "list_versions",
         "get_version",
         "activity",
+        "restore_version",
     }
     class_permission_name = "Chart"
     # Custom methods (``restore``) need an explicit entry; FAB's @protect()
@@ -171,6 +173,7 @@ class ChartRestApi(SoftDeleteApiMixin, 
BaseSupersetModelRestApi):
     method_permission_name = {
         **MODEL_API_RW_METHOD_PERMISSION_MAP,
         "restore": "write",
+        "restore_version": "write",
     }
 
     list_columns = [
@@ -1604,3 +1607,68 @@ class ChartRestApi(SoftDeleteApiMixin, 
BaseSupersetModelRestApi):
         from superset.versioning.activity import activity_endpoint
 
         return activity_endpoint(self, Slice, uuid_str, request.args)
+
+    @expose(
+        "/<uuid_str>/versions/<version_uuid_str>/restore",
+        methods=("POST",),
+    )
+    @protect()
+    @safe
+    @statsd_metrics
+    @event_logger.log_this_with_context(
+        action=lambda self, *args, **kwargs: (
+            f"{self.__class__.__name__}.restore_version"
+        ),
+        log_to_statsd=False,
+    )
+    def restore_version(self, uuid_str: str, version_uuid_str: str) -> 
Response:
+        """Restore a chart to a previous version.
+        ---
+        post:
+          summary: Revert a chart to an earlier version (non-destructive)
+          parameters:
+          - in: path
+            schema:
+              type: string
+              format: uuid
+            name: uuid_str
+            description: Chart UUID
+          - in: path
+            schema:
+              type: string
+              format: uuid
+            name: version_uuid_str
+            description: >-
+              Version UUID as returned by the list-versions endpoint.
+              Stable across retention pruning.
+          responses:
+            200:
+              description: Chart was restored
+              content:
+                application/json:
+                  schema:
+                    type: object
+                    properties:
+                      message:
+                        type: string
+            400:
+              $ref: '#/components/responses/400'
+            401:
+              $ref: '#/components/responses/401'
+            403:
+              $ref: '#/components/responses/403'
+            404:
+              $ref: '#/components/responses/404'
+            422:
+              $ref: '#/components/responses/422'
+        """
+        # pylint: disable=import-outside-toplevel
+        # Local import: the command module transitively imports the
+        # versioning bootstrap graph; see changes/listener.py.
+        from superset.commands.chart.restore_version import (
+            RestoreChartVersionCommand,
+        )
+
+        return restore_version_endpoint(
+            self, Slice, RestoreChartVersionCommand, uuid_str, version_uuid_str
+        )
diff --git a/superset/commands/chart/restore_version.py 
b/superset/commands/chart/restore_version.py
new file mode 100644
index 00000000000..a7b011e2d6b
--- /dev/null
+++ b/superset/commands/chart/restore_version.py
@@ -0,0 +1,44 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Command that restores a chart to a previous version."""
+
+from __future__ import annotations
+
+from superset.commands.chart.exceptions import (
+    ChartForbiddenError,
+    ChartNotFoundError,
+    ChartUpdateFailedError,
+)
+from superset.commands.version_restore import BaseRestoreVersionCommand
+from superset.models.slice import Slice
+
+
+class RestoreChartVersionCommand(BaseRestoreVersionCommand):
+    """Revert a chart to a previous version.
+
+    The restore is non-destructive: it produces a new version row (authored
+    by the restoring user), so prior versions remain in the history and the
+    change is itself reversible. The base builds the ``@transaction``
+    boundary from :attr:`failed_exc`, binding the commit that fires
+    Continuum's ``after_flush`` hook — the one that writes the new version
+    row — to this command's lifecycle.
+    """
+
+    model_cls = Slice
+    not_found_exc = ChartNotFoundError
+    forbidden_exc = ChartForbiddenError
+    failed_exc = ChartUpdateFailedError
diff --git a/superset/commands/dashboard/restore_version.py 
b/superset/commands/dashboard/restore_version.py
new file mode 100644
index 00000000000..07e1c546362
--- /dev/null
+++ b/superset/commands/dashboard/restore_version.py
@@ -0,0 +1,45 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Command that restores a dashboard to a previous version."""
+
+from __future__ import annotations
+
+from superset.commands.dashboard.exceptions import (
+    DashboardForbiddenError,
+    DashboardNotFoundError,
+    DashboardUpdateFailedError,
+)
+from superset.commands.version_restore import BaseRestoreVersionCommand
+from superset.models.dashboard import Dashboard
+
+
+class RestoreDashboardVersionCommand(BaseRestoreVersionCommand):
+    """Revert a dashboard to a previous version.
+
+    Restores the dashboard's own fields and its chart *membership* — which
+    charts sit on it — reattaching only charts that still exist (snapshot
+    members deleted since the snapshot stay deleted and are reported as
+    skipped). Member charts' content is never modified; restoring a
+    chart's content is the chart restore endpoint's job. See
+    :class:`superset.commands.version_restore.BaseRestoreVersionCommand`
+    for the general contract.
+    """
+
+    model_cls = Dashboard
+    not_found_exc = DashboardNotFoundError
+    forbidden_exc = DashboardForbiddenError
+    failed_exc = DashboardUpdateFailedError
diff --git a/superset/commands/dataset/restore_version.py 
b/superset/commands/dataset/restore_version.py
new file mode 100644
index 00000000000..045885baeef
--- /dev/null
+++ b/superset/commands/dataset/restore_version.py
@@ -0,0 +1,41 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Command that restores a dataset (and its columns/metrics) to a
+previous version."""
+
+from __future__ import annotations
+
+from superset.commands.dataset.exceptions import (
+    DatasetForbiddenError,
+    DatasetNotFoundError,
+    DatasetUpdateFailedError,
+)
+from superset.commands.version_restore import BaseRestoreVersionCommand
+from superset.connectors.sqla.models import SqlaTable
+
+
+class RestoreDatasetVersionCommand(BaseRestoreVersionCommand):
+    """Revert a dataset (and its columns + metrics — the aggregate's own
+    parts) to a previous version. See
+    :class:`superset.commands.chart.restore_version.RestoreChartVersionCommand`
+    for the general contract.
+    """
+
+    model_cls = SqlaTable
+    not_found_exc = DatasetNotFoundError
+    forbidden_exc = DatasetForbiddenError
+    failed_exc = DatasetUpdateFailedError
diff --git a/superset/commands/version_restore.py 
b/superset/commands/version_restore.py
new file mode 100644
index 00000000000..93bb7cb3a62
--- /dev/null
+++ b/superset/commands/version_restore.py
@@ -0,0 +1,152 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Shared base for the per-entity restore-version commands.
+
+The three concrete commands (:mod:`superset.commands.chart.restore_version`,
+:mod:`superset.commands.dashboard.restore_version`,
+:mod:`superset.commands.dataset.restore_version`) differ only in:
+
+* the model class they operate on
+* the per-entity ``NotFoundError`` / ``ForbiddenError`` / ``UpdateFailedError``
+  triplet they raise
+
+Everything else — capture gate, lookup, editorship check, version-uuid
+resolution, action-kind stamping, restore dispatch, transactional
+boundary — lives here. Subclasses are pure declarations: the base builds
+the ``@transaction`` wrapper at call time from the ``failed_exc``
+ClassVar (mirroring ``BaseRestoreCommand``), so a new entity rollout
+cannot forget the decorator.
+"""
+
+from __future__ import annotations
+
+from functools import partial
+from typing import Any, ClassVar
+from uuid import UUID
+
+from superset import security_manager
+from superset.commands.base import BaseCommand
+from superset.exceptions import SupersetSecurityException
+from superset.extensions import db
+from superset.utils.decorators import on_error, transaction
+from superset.versioning.queries import find_active_by_uuid, resolve_version
+from superset.versioning.restore import restore_version, RestoreResult
+from superset.versioning.utils import capture_enabled
+
+
+class BaseRestoreVersionCommand(BaseCommand):
+    """Workflow for a non-destructive version restore on one entity.
+
+    Subclasses declare the model class plus the three entity-specific
+    exception ClassVars; the base owns the workflow and the transactional
+    boundary.
+    """
+
+    #: Subclass overrides — the versioned model class (``Slice`` /
+    #: ``Dashboard`` / ``SqlaTable``).
+    model_cls: ClassVar[type]
+
+    #: Subclass overrides — exception classes raised on the matching
+    #: failure modes. ``not_found_exc`` covers "no such entity",
+    #: "version_uuid not on this entity", and "capture disabled" (the
+    #: route is inert under the kill-switch); the API handler maps each
+    #: to HTTP 404. ``forbidden_exc`` covers the row-level editorship
+    #: denial (HTTP 403). ``failed_exc`` wraps unexpected failures inside
+    #: the transaction (HTTP 422).
+    not_found_exc: ClassVar[type[Exception]]
+    forbidden_exc: ClassVar[type[Exception]]
+    failed_exc: ClassVar[type[Exception]]
+
+    def __init__(self, entity_uuid: UUID, version_uuid: UUID) -> None:
+        self._uuid = entity_uuid
+        self._version_uuid = version_uuid
+
+    def run(self) -> RestoreResult:
+        # Build the transactional wrapper at call time so ``on_error`` can
+        # reference ``self.failed_exc`` — a per-subclass ClassVar that
+        # isn't available when this method is defined on the base (same
+        # pattern and rationale as ``BaseRestoreCommand.run``).
+        @transaction(on_error=partial(on_error, reraise=self.failed_exc))
+        def _perform() -> RestoreResult:
+            return self._do_restore()
+
+        return _perform()
+
+    def _do_restore(self) -> RestoreResult:
+        entity = self.validate()
+        resolved = resolve_version(
+            self.model_cls, self._uuid, self._version_uuid, entity=entity
+        )
+        if resolved is None:
+            raise self.not_found_exc()
+        version_number, transaction_id = resolved
+
+        # Stamp the transaction so the change-record listener writes
+        # ``action_kind='restore'`` and the ``__meta__`` headline — the
+        # activity feed renders the restore as "Restored to version N",
+        # not an ordinary save. Contract documented in
+        # ``versioning/changes/listener.py`` (import/clone stamp the same
+        # way); the listener pops both keys after use.
+        # pylint: disable=import-outside-toplevel
+        # Local import: the changes package bootstraps the versioning
+        # listener graph; see its module docstring for the init-order
+        # rationale.
+        from superset.versioning.changes import (
+            ACTION_KIND_KEY,
+            ACTION_KIND_RESTORE,
+            ACTION_META_KEY,
+            build_action_headline,
+            ENTITY_KIND_BY_CLASS_NAME,
+        )
+
+        db.session.info[ACTION_KIND_KEY] = ACTION_KIND_RESTORE
+        entity_kind = ENTITY_KIND_BY_CLASS_NAME.get(self.model_cls.__name__)
+        if entity_kind is not None:
+            db.session.info[ACTION_META_KEY] = build_action_headline(
+                entity_kind,
+                entity.id,
+                {
+                    "version_uuid": str(self._version_uuid),
+                    "version_number": version_number,
+                },
+            )
+
+        result = restore_version(
+            self.model_cls, self._uuid, transaction_id, entity=entity
+        )
+        if result is None:
+            # Race: entity deleted, or the target version row pruned,
+            # between validate()/resolve and the engine's re-check.
+            raise self.not_found_exc()
+        return result
+
+    def validate(self) -> Any:
+        # With capture off, Continuum's write listeners are detached: a
+        # revert would mutate the live entity with NO new version row —
+        # a destructive, untracked write. The whole restore surface is
+        # therefore inert under the kill-switch, matching the read-side
+        # convention (404, indistinguishable from "no such version").
+        if not capture_enabled():
+            raise self.not_found_exc()
+        entity = find_active_by_uuid(self.model_cls, self._uuid)
+        if entity is None:
+            raise self.not_found_exc()
+        try:
+            security_manager.raise_for_editorship(entity)
+        except SupersetSecurityException as ex:
+            raise self.forbidden_exc() from ex
+        return entity
diff --git a/superset/daos/version.py b/superset/daos/version.py
index f73c7b4e347..23d2f555a6a 100644
--- a/superset/daos/version.py
+++ b/superset/daos/version.py
@@ -16,12 +16,12 @@
 # under the License.
 """Backward-compat façade for the entity-versioning DAO surface.
 
-The actual implementation lives in :mod:`superset.versioning.queries`
-(read side: list/get/resolve/find/UUID derivation). This module
-re-exports it under a single ``VersionDAO`` class plus the module-level
-UUID helpers so existing callers keep working without changes. (The
-write side — restore + audit stamping — ships in a later PR; only the
-read surface is wired here.)
+The read side lives in :mod:`superset.versioning.queries`
+(list/get/resolve/find/UUID derivation) and the write side in
+:mod:`superset.versioning.restore` (non-destructive version restore).
+This module re-exports both under a single ``VersionDAO`` class plus the
+module-level UUID helpers so existing callers keep working without
+changes.
 
 New code should import from the versioning sub-modules directly.
 """
@@ -38,9 +38,11 @@ from superset.versioning.queries import (
     get_version,
     list_change_records_batch,
     list_versions,
+    resolve_version,
     resolve_version_uuid,
     VERSION_UUID_NAMESPACE,
 )
+from superset.versioning.restore import restore_version
 
 # Re-exports for ``from superset.daos.version import …`` consumers.
 __all__ = [
@@ -65,5 +67,9 @@ class VersionDAO:
     current_live_version_uuid = staticmethod(current_live_version_uuid)
     list_change_records_batch = staticmethod(list_change_records_batch)
     list_versions = staticmethod(list_versions)
+    resolve_version = staticmethod(resolve_version)
     resolve_version_uuid = staticmethod(resolve_version_uuid)
     get_version = staticmethod(get_version)
+
+    # --- write side (restore.py) ------------------------------------------
+    restore_version = staticmethod(restore_version)
diff --git a/superset/dashboards/api.py b/superset/dashboards/api.py
index 5a138d0d401..4061ddb4aca 100644
--- a/superset/dashboards/api.py
+++ b/superset/dashboards/api.py
@@ -168,6 +168,7 @@ from superset.versioning.api_helpers import (
     current_entity_version_info,
     get_version_endpoint,
     list_versions_endpoint,
+    restore_version_endpoint,
 )
 from superset.versioning.etag import set_version_etag
 from superset.versioning.schemas import VersionListItemSchema
@@ -291,6 +292,7 @@ class DashboardRestApi(
         "list_versions",
         "get_version",
         "activity",
+        "restore_version",
     }
     resource_name = "dashboard"
     allow_browser_login = True
@@ -306,6 +308,7 @@ class DashboardRestApi(
     method_permission_name = {
         **MODEL_API_RW_METHOD_PERMISSION_MAP,
         "restore": "write",
+        "restore_version": "write",
         # Reuse the dashboard ``can_export`` permission (the frontend gates the
         # menu item on it) instead of the ``can_export_xlsx`` FAB would 
otherwise
         # derive from the method name.
@@ -2774,3 +2777,72 @@ class DashboardRestApi(
         from superset.versioning.activity import activity_endpoint
 
         return activity_endpoint(self, Dashboard, uuid_str, request.args)
+
+    @expose(
+        "/<uuid_str>/versions/<version_uuid_str>/restore",
+        methods=("POST",),
+    )
+    @protect()
+    @safe
+    @statsd_metrics
+    @event_logger.log_this_with_context(
+        action=lambda self, *args, **kwargs: (
+            f"{self.__class__.__name__}.restore_version"
+        ),
+        log_to_statsd=False,
+    )
+    def restore_version(self, uuid_str: str, version_uuid_str: str) -> 
Response:
+        """Restore a dashboard to a previous version.
+        ---
+        post:
+          summary: Revert a dashboard to an earlier version (non-destructive)
+          parameters:
+          - in: path
+            schema:
+              type: string
+              format: uuid
+            name: uuid_str
+            description: Dashboard UUID
+          - in: path
+            schema:
+              type: string
+              format: uuid
+            name: version_uuid_str
+            description: >-
+              Version UUID as returned by the list-versions endpoint.
+              Stable across retention pruning.
+          responses:
+            200:
+              description: Dashboard was restored
+              content:
+                application/json:
+                  schema:
+                    type: object
+                    properties:
+                      message:
+                        type: string
+            400:
+              $ref: '#/components/responses/400'
+            401:
+              $ref: '#/components/responses/401'
+            403:
+              $ref: '#/components/responses/403'
+            404:
+              $ref: '#/components/responses/404'
+            422:
+              $ref: '#/components/responses/422'
+        """
+        # pylint: disable=import-outside-toplevel
+        # Local import: the command module transitively imports the
+        # versioning bootstrap graph; see changes/listener.py.
+        from superset.commands.dashboard.restore_version import (
+            RestoreDashboardVersionCommand,
+        )
+
+        return restore_version_endpoint(
+            self,
+            Dashboard,
+            RestoreDashboardVersionCommand,
+            uuid_str,
+            version_uuid_str,
+        )
diff --git a/superset/datasets/api.py b/superset/datasets/api.py
index 46be170e868..3ee633f94f1 100644
--- a/superset/datasets/api.py
+++ b/superset/datasets/api.py
@@ -97,6 +97,7 @@ from superset.versioning.api_helpers import (
     current_entity_version_info,
     get_version_endpoint,
     list_versions_endpoint,
+    restore_version_endpoint,
 )
 from superset.versioning.etag import set_version_etag
 from superset.versioning.schemas import VersionListItemSchema
@@ -135,6 +136,7 @@ class DatasetRestApi(SoftDeleteApiMixin, 
BaseSupersetModelRestApi):
     method_permission_name = {
         **MODEL_API_RW_METHOD_PERMISSION_MAP,
         "restore": "write",
+        "restore_version": "write",
     }
     include_route_methods = RouteMethod.REST_MODEL_VIEW_CRUD_SET | {
         RouteMethod.EXPORT,
@@ -152,6 +154,7 @@ class DatasetRestApi(SoftDeleteApiMixin, 
BaseSupersetModelRestApi):
         "list_versions",
         "get_version",
         "activity",
+        "restore_version",
     }
     list_columns = [
         "id",
@@ -1952,3 +1955,72 @@ class DatasetRestApi(SoftDeleteApiMixin, 
BaseSupersetModelRestApi):
         from superset.versioning.activity import activity_endpoint
 
         return activity_endpoint(self, SqlaTable, uuid_str, request.args)
+
+    @expose(
+        "/<uuid_str>/versions/<version_uuid_str>/restore",
+        methods=("POST",),
+    )
+    @protect()
+    @safe
+    @statsd_metrics
+    @event_logger.log_this_with_context(
+        action=lambda self, *args, **kwargs: (
+            f"{self.__class__.__name__}.restore_version"
+        ),
+        log_to_statsd=False,
+    )
+    def restore_version(self, uuid_str: str, version_uuid_str: str) -> 
Response:
+        """Restore a dataset to a previous version.
+        ---
+        post:
+          summary: Revert a dataset to an earlier version (non-destructive)
+          parameters:
+          - in: path
+            schema:
+              type: string
+              format: uuid
+            name: uuid_str
+            description: Dataset UUID
+          - in: path
+            schema:
+              type: string
+              format: uuid
+            name: version_uuid_str
+            description: >-
+              Version UUID as returned by the list-versions endpoint.
+              Stable across retention pruning.
+          responses:
+            200:
+              description: Dataset was restored
+              content:
+                application/json:
+                  schema:
+                    type: object
+                    properties:
+                      message:
+                        type: string
+            400:
+              $ref: '#/components/responses/400'
+            401:
+              $ref: '#/components/responses/401'
+            403:
+              $ref: '#/components/responses/403'
+            404:
+              $ref: '#/components/responses/404'
+            422:
+              $ref: '#/components/responses/422'
+        """
+        # pylint: disable=import-outside-toplevel
+        # Local import: the command module transitively imports the
+        # versioning bootstrap graph; see changes/listener.py.
+        from superset.commands.dataset.restore_version import (
+            RestoreDatasetVersionCommand,
+        )
+
+        return restore_version_endpoint(
+            self,
+            SqlaTable,
+            RestoreDatasetVersionCommand,
+            uuid_str,
+            version_uuid_str,
+        )
diff --git a/superset/initialization/__init__.py 
b/superset/initialization/__init__.py
index 36a5fc31bd8..9bd94bd54ef 100644
--- a/superset/initialization/__init__.py
+++ b/superset/initialization/__init__.py
@@ -788,8 +788,11 @@ class SupersetAppInitializer:  # pylint: 
disable=too-many-public-methods
                 "versioning: ENABLE_VERSIONING_CAPTURE is False; "
                 "skipping baseline + change-record listener registration "
                 "and detaching Continuum's write listeners. Save-path "
-                "capture is disabled; existing shadow tables and "
-                "/versions/ endpoints continue to work read-only."
+                "capture is disabled; existing shadow tables and the "
+                "read-side /versions/ endpoints continue to work "
+                "read-only, and the version-restore endpoints refuse "
+                "with 404 (a restore without capture would be a "
+                "destructive, untracked write)."
             )
             self._remove_continuum_write_listeners()
             return
diff --git a/superset/versioning/api_helpers.py 
b/superset/versioning/api_helpers.py
index 705bb2f46a1..72fd6f4627b 100644
--- a/superset/versioning/api_helpers.py
+++ b/superset/versioning/api_helpers.py
@@ -25,18 +25,21 @@ lets each per-resource method collapse to a single 
delegation call, while
 the OpenAPI docstring + FAB decorators stay at the method site where they
 belong.
 
-(The restore endpoint ships in a later PR; only the read + activity
-endpoints are wired here.)
+The write side follows the same pattern: ``restore_version_endpoint``
+holds the shared body of the three ``POST .../versions/<uuid>/restore``
+routes; authorization and the capture kill-switch gate live in the
+restore command's ``validate()``, not here.
 """
 
 from __future__ import annotations
 
+import logging
 from dataclasses import dataclass
 from typing import Any
 from uuid import UUID
 
 import sqlalchemy as sa
-from flask import current_app, Response
+from flask import Response
 from flask_appbuilder import Model
 
 from superset.daos.version import VersionDAO
@@ -53,6 +56,8 @@ from superset.versioning.schemas import VersionListItemSchema
 #: carry UUID instances, the snapshot block pre-stringifies).
 _version_item_schema = VersionListItemSchema()
 
+logger = logging.getLogger(__name__)
+
 
 @dataclass
 class EntityVersionInfo:
@@ -70,7 +75,13 @@ class EntityVersionInfo:
 
 
 def _capture_enabled() -> bool:
-    return bool(current_app.config.get("ENABLE_VERSIONING_CAPTURE", False))
+    # Delegates to the shared gate so the read helpers and the restore
+    # command can't disagree about what "capture is on" means.
+    from superset.versioning.utils import (  # pylint: 
disable=import-outside-toplevel
+        capture_enabled,
+    )
+
+    return capture_enabled()
 
 
 def current_entity_version_info(
@@ -264,3 +275,56 @@ def get_version_endpoint(
         entity_uuid,
         entity_id=entity.id,
     )
+
+
+def restore_version_endpoint(
+    api: Any,
+    model_cls: type[Model],
+    command_cls: type[Any],
+    uuid_str: str,
+    version_uuid_str: str,
+) -> Response:
+    """Body of ``POST 
/api/v1/{resource}/<uuid>/versions/<version_uuid>/restore``.
+
+    *command_cls* is the entity's ``BaseRestoreVersionCommand`` subclass;
+    its ``not_found_exc`` / ``forbidden_exc`` / ``failed_exc`` ClassVars
+    drive the exception→HTTP mapping, so this body stays generic.
+    Authorization and the ``ENABLE_VERSIONING_CAPTURE`` kill-switch gate
+    live in the command's ``validate()`` — with capture off the route is
+    inert (404) because a revert without Continuum's write listeners
+    would be a destructive, untracked write.
+    """
+    try:
+        entity_uuid = UUID(uuid_str)
+    except ValueError:
+        return api.response_400(message="Invalid UUID")
+    try:
+        version_uuid = UUID(version_uuid_str)
+    except ValueError:
+        return api.response_400(message="Invalid version UUID")
+
+    try:
+        result = command_cls(entity_uuid, version_uuid).run()
+    except command_cls.not_found_exc:
+        return api.response_404()
+    except command_cls.forbidden_exc:
+        return api.response_403()
+    except command_cls.failed_exc as ex:
+        logger.exception("Error restoring %s version", model_cls.__name__)
+        return api.response_422(message=str(ex))
+
+    message = "OK"
+    if result.skipped_slice_ids:
+        message = (
+            f"OK; {len(result.skipped_slice_ids)} chart(s) referenced by "
+            "the snapshot no longer exist and were not reattached"
+        )
+    return set_version_etag_by_uuid(
+        api.response(200, message=message),
+        model_cls,
+        entity_uuid,
+        # The command already loaded the entity; passing its id skips the
+        # extra id-by-uuid SELECT (same optimization as the sibling
+        # list/get endpoints).
+        entity_id=result.entity.id,
+    )
diff --git a/superset/versioning/baseline/__init__.py 
b/superset/versioning/baseline/__init__.py
index 664af88c4cb..7c1acaee6b0 100644
--- a/superset/versioning/baseline/__init__.py
+++ b/superset/versioning/baseline/__init__.py
@@ -57,10 +57,12 @@ from superset.versioning.baseline.listener import 
register_baseline_listener
 from superset.versioning.baseline.shadow import (
     CONTINUUM_BOOKKEEPING_COLUMNS,
     insert_baseline_shadow_row,
+    OPERATION_DELETE,
 )
 
 __all__ = [
     "CONTINUUM_BOOKKEEPING_COLUMNS",
+    "OPERATION_DELETE",
     "VERSIONED_MODELS",
     "child_to_parent_registry",
     "insert_baseline_shadow_row",
diff --git a/superset/versioning/baseline/shadow.py 
b/superset/versioning/baseline/shadow.py
index 49d5980467a..0656cb72900 100644
--- a/superset/versioning/baseline/shadow.py
+++ b/superset/versioning/baseline/shadow.py
@@ -46,6 +46,11 @@ CONTINUUM_BOOKKEEPING_COLUMNS: frozenset[str] = frozenset(
     {"transaction_id", "end_transaction_id", "operation_type"}
 )
 
+#: Continuum ``operation_type`` code for a DELETE version row. Shared by
+#: every validity-window predicate (a DELETE shadow row is never "live at
+#: tx") and by the restore engine's refuse-DELETE-target guard.
+OPERATION_DELETE: int = 2
+
 
 def insert_baseline_shadow_row(
     conn: Any,
diff --git a/superset/versioning/changes/shadow_queries.py 
b/superset/versioning/changes/shadow_queries.py
index 1f6a290007f..250fa5c1fc5 100644
--- a/superset/versioning/changes/shadow_queries.py
+++ b/superset/versioning/changes/shadow_queries.py
@@ -37,7 +37,10 @@ from typing import Any
 import sqlalchemy as sa
 from sqlalchemy.orm import Session
 
-from superset.versioning.baseline import CONTINUUM_BOOKKEEPING_COLUMNS
+from superset.versioning.baseline import (
+    CONTINUUM_BOOKKEEPING_COLUMNS,
+    OPERATION_DELETE,
+)
 from superset.versioning.changes.state import jsonable
 from superset.versioning.diff import (
     ChangeRecord,
@@ -76,7 +79,7 @@ def shadow_rows_valid_at(
                     shadow_table.c.end_transaction_id.is_(None),
                     shadow_table.c.end_transaction_id > tx,
                 ),
-                shadow_table.c.operation_type != 2,
+                shadow_table.c.operation_type != OPERATION_DELETE,
             )
         )
         .mappings()
@@ -266,13 +269,13 @@ def _dashboard_slice_uuids_at_tx(
                     m2m_tbl.c.end_transaction_id.is_(None),
                     m2m_tbl.c.end_transaction_id > tx,
                 ),
-                m2m_tbl.c.operation_type != 2,
+                m2m_tbl.c.operation_type != OPERATION_DELETE,
                 slices_tbl.c.transaction_id <= tx,
                 sa.or_(
                     slices_tbl.c.end_transaction_id.is_(None),
                     slices_tbl.c.end_transaction_id > tx,
                 ),
-                slices_tbl.c.operation_type != 2,
+                slices_tbl.c.operation_type != OPERATION_DELETE,
             )
         )
         .all()
diff --git a/superset/versioning/queries.py b/superset/versioning/queries.py
index 29ffd103cb5..47b36c30430 100644
--- a/superset/versioning/queries.py
+++ b/superset/versioning/queries.py
@@ -18,8 +18,8 @@
 
 Pure-read helpers that translate Continuum shadow rows and
 ``version_changes`` records into the shapes the API endpoints return.
-The corresponding write side (restore) ships in a later PR; the
-``VersionDAO`` façade in :mod:`superset.daos.version` re-exports the
+The corresponding write side lives in :mod:`superset.versioning.restore`;
+the ``VersionDAO`` façade in :mod:`superset.daos.version` re-exports the
 read helpers here.
 
 Also exposes the deterministic version-UUID derivation
@@ -363,20 +363,23 @@ def list_versions(
     ]
 
 
-def resolve_version_uuid(
+def resolve_version(
     model_cls: type[Model],
     entity_uuid: UUID,
     version_uuid: UUID,
     *,
     entity: Any | None = None,
-) -> int | None:
-    """Translate a ``version_uuid`` into the 0-based ``version_number`` that 
the
-    restore path (ships in a later PR) accepts, or ``None`` when the UUID does
-    not match any version row of the given entity.
-
-    Ordering matches :func:`list_versions` — op=0 rows first, then by
-    transaction_id — so the version_number returned here is the same index
-    a client would see in the list response.
+) -> tuple[int, int] | None:
+    """Translate a ``version_uuid`` into ``(version_number, transaction_id)``,
+    or ``None`` when the UUID does not match any version row of the given
+    entity.
+
+    ``version_number`` is the 0-based display index matching
+    :func:`list_versions` (op=0 rows first, then by transaction_id).
+    ``transaction_id`` is the stable identifier — write paths must address
+    the target row by it, never by the positional index, because the index
+    shifts whenever retention pruning removes older rows (see the
+    ``current_version_number`` docstring).
 
     Implementation note: the loop re-derives ``version_uuid`` per
     transaction in Python because there's no portable SQL form for a
@@ -410,10 +413,26 @@ def resolve_version_uuid(
     )
     for version_number, (tx_id,) in enumerate(tx_ids):
         if derive_version_uuid(entity_uuid, tx_id) == version_uuid:
-            return version_number
+            return version_number, tx_id
     return None
 
 
+def resolve_version_uuid(
+    model_cls: type[Model],
+    entity_uuid: UUID,
+    version_uuid: UUID,
+    *,
+    entity: Any | None = None,
+) -> int | None:
+    """Translate a ``version_uuid`` into its 0-based ``version_number``.
+
+    Thin wrapper over :func:`resolve_version` for read-side callers that
+    only need the display index.
+    """
+    resolved = resolve_version(model_cls, entity_uuid, version_uuid, 
entity=entity)
+    return None if resolved is None else resolved[0]
+
+
 def get_version(
     model_cls: type[Model],
     entity_uuid: UUID,
diff --git a/superset/versioning/restore.py b/superset/versioning/restore.py
new file mode 100644
index 00000000000..a088ecdc8ca
--- /dev/null
+++ b/superset/versioning/restore.py
@@ -0,0 +1,264 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Write-side: restore a versioned entity to an earlier state.
+
+Companion to :mod:`superset.versioning.queries`. The
+``BaseRestoreVersionCommand`` in :mod:`superset.commands.version_restore`
+is the only intended caller; the backward-compat ``VersionDAO`` façade
+in :mod:`superset.daos.version` re-exports ``restore_version``.
+
+Restore semantics are strictly per-entity: a restore rewrites the target
+entity's own fields (and, for datasets, its own columns/metrics — the
+aggregate's internal parts), never the content of other entities. A
+dashboard restore reattaches membership to charts that still exist;
+charts that have been deleted since the snapshot stay deleted and are
+reported as skipped rather than revived or dangling.
+"""
+
+from __future__ import annotations
+
+import logging
+from dataclasses import dataclass, field
+from datetime import datetime
+from typing import Any
+from uuid import UUID
+
+from sqlalchemy_continuum import version_class
+
+from superset.extensions import db
+from superset.versioning.baseline import OPERATION_DELETE
+from superset.versioning.queries import find_active_by_uuid
+from superset.versioning.utils import single_flush_scope
+
+logger = logging.getLogger(__name__)
+
+# A DELETE version row (``OPERATION_DELETE``) is never a valid restore
+# target: Continuum's ``Reverter`` would delete the live entity and report
+# success — the opposite of the non-destructive contract — so the engine
+# treats it as not-found.
+
+
+# Per-model relationships that Continuum's Reverter recurses into during a
+# restore — deliberately limited to the entity's OWN aggregate parts
+# (``TableColumn`` / ``SqlMetric`` on ``SqlaTable``). ``Dashboard`` is NOT
+# given ``slices`` here: recursing into the M2M would run a full child
+# revert on every member chart, overwriting live charts' content with
+# historical values (charts are shared entities with their own restore),
+# and re-creating hard-deleted charts. Dashboard membership is instead
+# reconstructed by :func:`_restore_dashboard_membership`.
+#
+# Unknown models fail closed (``LookupError``) rather than defaulting to a
+# relation-less restore — a silently partial restore is worse than a loud
+# failure (mirrors ``_RAISE_FOR_ACCESS_KWARG`` in ``api_helpers``).
+_RESTORE_RELATIONS: dict[str, list[str]] = {
+    "SqlaTable": ["columns", "metrics"],
+    "Dashboard": [],
+    "Slice": [],
+}
+
+
+@dataclass
+class RestoreResult:
+    """Outcome of a successful restore.
+
+    ``skipped_slice_ids`` is only ever populated for dashboard restores:
+    member charts referenced by the snapshot that no longer exist and were
+    therefore not reattached (they stay deleted — restore never revives
+    entities).
+    """
+
+    entity: Any
+    skipped_slice_ids: list[int] = field(default_factory=list)
+
+
+def restore_version(
+    model_cls: type,
+    entity_uuid: UUID,
+    transaction_id: int,
+    *,
+    entity: Any | None = None,
+) -> RestoreResult | None:
+    """Restore the entity identified by *entity_uuid* to the state captured
+    at *transaction_id* (the stable identifier resolved from a
+    ``version_uuid`` by :func:`superset.versioning.queries.resolve_version`).
+
+    Returns a :class:`RestoreResult` wrapping the live entity, or ``None``
+    when the UUID does not match an active entity, no version row exists at
+    *transaction_id*, or the target row is a DELETE — callers should
+    translate all three to a 404.
+
+    Pass *entity* to skip the ``find_active_by_uuid`` lookup when the
+    caller has already loaded the row (the command's ``validate()`` has).
+
+    Uses SQLAlchemy-Continuum's native ``version_obj.revert(relations=...)``
+    and delegates commit to the caller (expected to be a command decorated
+    with ``@transaction()``). The ``relations`` list depends on the model
+    type and is looked up in :data:`_RESTORE_RELATIONS`; unknown models
+    raise ``LookupError`` rather than silently restoring without children.
+
+    Within the same flush, ``changed_on`` / ``changed_by_fk`` are
+    re-stamped with the current time and the restoring user's id so the
+    new version row produced by the restoring commit reflects who clicked
+    Restore, not the original author. ``created_on`` / ``created_by_fk``
+    are left alone.
+    """
+    if entity is None:
+        entity = find_active_by_uuid(model_cls, entity_uuid)
+        if entity is None:
+            return None
+    elif entity.uuid != entity_uuid:
+        # The caller-supplied shortcut must describe the same row as
+        # *entity_uuid*: everything downstream (the version lookup, the
+        # audit stamp, the caller's logging) trusts them to agree. Fail
+        # loudly rather than restore one entity while reporting another.
+        raise ValueError(
+            f"entity.uuid ({entity.uuid!r}) does not match entity_uuid "
+            f"({entity_uuid!r}); the preloaded entity must be the one "
+            "identified by entity_uuid"
+        )
+
+    ver_cls = version_class(model_cls)
+    target_version = (
+        db.session.query(ver_cls)
+        .filter(
+            ver_cls.id == entity.id,
+            ver_cls.transaction_id == transaction_id,
+        )
+        .one_or_none()
+    )
+    if target_version is None or target_version.operation_type == 
OPERATION_DELETE:
+        return None
+
+    relations = _RESTORE_RELATIONS.get(model_cls.__name__)
+    if relations is None:
+        raise LookupError(
+            f"No restore relations registered for {model_cls.__name__!r}; "
+            "register the model in _RESTORE_RELATIONS before wiring a "
+            "restore command for it."
+        )
+
+    # Run the whole revert — including membership reconstruction and audit
+    # stamping — inside a single flush scope so SQLAlchemy-Continuum's
+    # ``Reverter`` can iterate relations without tripping its autoflush
+    # race, and so the change-records listener sees the complete state in
+    # one ``after_flush`` pass. See ``single_flush_scope`` for the full
+    # rationale.
+    skipped_slice_ids: list[int] = []
+    try:
+        with single_flush_scope(db.session):
+            target_version.revert(relations=relations)
+            if model_cls.__name__ == "Dashboard":
+                skipped_slice_ids = _restore_dashboard_membership(
+                    entity, transaction_id
+                )
+            _stamp_audit_fields_for_restore(entity)
+    except Exception:
+        logger.exception(
+            "Continuum revert() failed for %s id=%s tx=%s relations=%s",
+            model_cls.__name__,
+            entity.id,
+            transaction_id,
+            relations,
+        )
+        raise
+
+    logger.info(
+        "versioning: restored %s id=%s uuid=%s to tx=%s (skipped_slices=%s)",
+        model_cls.__name__,
+        entity.id,
+        entity_uuid,
+        transaction_id,
+        skipped_slice_ids or None,
+    )
+    return RestoreResult(entity=entity, skipped_slice_ids=skipped_slice_ids)
+
+
+def _restore_dashboard_membership(dashboard: Any, transaction_id: int) -> 
list[int]:
+    """Reset *dashboard*'s chart membership to what it was at
+    *transaction_id*, reattaching only charts that still exist.
+
+    Reads the validity-windowed ``dashboard_slices_version`` shadow
+    (Continuum's auto-generated M2M table): a slice was a member at tx T
+    iff a non-DELETE row has ``transaction_id <= T`` and an open or
+    later-closing validity window.
+
+    Returns the ids of snapshot members that no longer exist and were
+    skipped. Live charts' content is never touched — restoring a chart's
+    content is the chart's own restore endpoint's job.
+    """
+    # pylint: disable=import-outside-toplevel
+    # Local imports: models.slice transitively imports models.core, which
+    # needs the initialised app — module-top import would recreate the
+    # bootstrap cycle documented in changes/listener.py; shadow_queries is
+    # imported lazily for the same reason (see queries.get_version).
+    from superset.models.slice import Slice
+    from superset.versioning.changes import shadow_rows_valid_at
+
+    ver_cls = version_class(type(dashboard))
+    m2m_tbl = ver_cls.__table__.metadata.tables.get("dashboard_slices_version")
+    if m2m_tbl is None:  # pragma: no cover — shadow tables always exist here
+        return []
+
+    # shadow_rows_valid_at owns the validity-window semantics (open or
+    # later-closing window, non-DELETE) — the same predicate the version
+    # snapshot's column/metric reconstruction uses.
+    member_ids = sorted(
+        {
+            row["slice_id"]
+            for row in shadow_rows_valid_at(
+                db.session,
+                m2m_tbl,
+                "dashboard_id",
+                dashboard.id,
+                transaction_id,
+            )
+            if row["slice_id"] is not None
+        }
+    )
+    if not member_ids:
+        dashboard.slices = []
+        return []
+
+    live_slices = 
db.session.query(Slice).filter(Slice.id.in_(member_ids)).all()
+    live_ids = {slc.id for slc in live_slices}
+    skipped = sorted(set(member_ids) - live_ids)
+    if skipped:
+        logger.warning(
+            "versioning: dashboard id=%s restore to tx=%s skipped %d "
+            "member chart(s) that no longer exist: %s",
+            dashboard.id,
+            transaction_id,
+            len(skipped),
+            skipped,
+        )
+    dashboard.slices = live_slices
+    return skipped
+
+
+def _stamp_audit_fields_for_restore(entity: Any) -> None:
+    """Overwrite ``changed_on`` / ``changed_by_fk`` on *entity* with the
+    current time and current user id, so that the restore is attributed
+    to the restoring user rather than the version snapshot's original
+    author. Runs inside the restore's single flush scope so the stamp
+    rides the same Continuum transaction as the revert."""
+    # pylint: disable=import-outside-toplevel
+    # Local import: utils.core pulls in the feature-flag manager, which
+    # needs the initialised app (same cycle as models.slice above).
+    from superset.utils.core import get_user_id
+
+    entity.changed_on = datetime.now()
+    entity.changed_by_fk = get_user_id()
diff --git a/superset/versioning/utils.py b/superset/versioning/utils.py
index 47fa3c23ace..3a740a971c5 100644
--- a/superset/versioning/utils.py
+++ b/superset/versioning/utils.py
@@ -18,12 +18,65 @@
 
 from __future__ import annotations
 
+from collections.abc import Iterator
+from contextlib import contextmanager
 from typing import Any
 
 import sqlalchemy as sa
+from flask import current_app
 from sqlalchemy.orm import Session
 
 
+def capture_enabled() -> bool:
+    """Whether ``ENABLE_VERSIONING_CAPTURE`` is on for the current app.
+
+    The single gate shared by the read helpers (which degrade to inert
+    responses when off) and the write side (which must refuse: with
+    Continuum's write listeners detached, a restore would mutate the live
+    entity with no new version row — a destructive, untracked write that
+    violates the append-only contract).
+
+    Operational constraint: this reads config at call time, but the
+    Continuum listeners themselves attach or detach only when
+    ``init_versioning()`` runs at startup. The two agree only because the
+    flag is static per-process — flipping it at runtime (dynamic config
+    reload, a future admin toggle) without re-running ``init_versioning()``
+    would let this gate pass while listeners stay detached, producing
+    exactly the untracked write it exists to prevent. Restart the process
+    (or re-run ``init_versioning()``) after changing the flag.
+    """
+    return bool(current_app.config.get("ENABLE_VERSIONING_CAPTURE", False))
+
+
+@contextmanager
+def single_flush_scope(session: Session) -> Iterator[None]:
+    """Suppress autoflushes inside the block, flush once on clean exit.
+
+    Intended for operations that (a) make multiple mutations across
+    relationships and (b) issue intermediate queries which would
+    otherwise autoflush. Iterating from one relationship to another
+    inside SQLAlchemy-Continuum's ``Reverter`` is the canonical case:
+    a mid-iteration autoflush transitions pending DELETEs to
+    ``state.deleted=True``, and the subsequent
+    ``session.add(version_parent)`` cascade walk trips on the
+    deleted-state instances with ``InvalidRequestError``. Wrapping the
+    whole revert keeps marked-for-deletion instances in
+    ``state.persistent`` until the trailing flush drains DELETEs +
+    INSERTs in one atomic step. That single flush is also load-bearing
+    for the ``after_flush`` change-records listener — splitting the
+    work across multiple flushes would split it across multiple
+    Continuum transactions, and the listener's tx-dedup guard would
+    silently drop the second pass's records.
+
+    On exception, the trailing flush is skipped — the session's normal
+    rollback flow handles cleanup, and flushing a partially-mutated
+    state would be wrong.
+    """
+    with session.no_autoflush:
+        yield
+    session.flush()
+
+
 def read_row_outside_flush(
     session: Session, table: sa.Table, entity_id: int
 ) -> dict[str, Any] | None:
diff --git a/tests/integration_tests/charts/version_restore_tests.py 
b/tests/integration_tests/charts/version_restore_tests.py
new file mode 100644
index 00000000000..3c60bfbb173
--- /dev/null
+++ b/tests/integration_tests/charts/version_restore_tests.py
@@ -0,0 +1,456 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Integration tests for chart (Slice) version restore.
+
+Covers POST /api/v1/chart/<uuid>/versions/<version_uuid>/restore: the
+non-destructive revert applies the target snapshot, appends a new
+version row, attributes the change to the restoring user, and returns
+the documented 400/404 errors for malformed or unknown UUIDs.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+import pytest
+from sqlalchemy_continuum import version_class
+
+from superset.extensions import db
+from superset.models.slice import Slice
+from superset.utils import json as _json
+from tests.integration_tests.base_tests import SupersetTestCase
+from tests.integration_tests.constants import ADMIN_USERNAME
+from tests.integration_tests.fixtures.birth_names_dashboard import (  # noqa: 
F401
+    load_birth_names_dashboard_with_slices,
+    load_birth_names_data,
+)
+
+
+def _get_version_rows(chart: Slice) -> list[Any]:
+    ver_cls = version_class(Slice)
+    return (
+        db.session.query(ver_cls)
+        .filter(ver_cls.id == chart.id)
+        .order_by(ver_cls.transaction_id.asc())
+        .all()
+    )
+
+
+def _persist_fixture_state() -> None:
+    """Force fixture's pending INSERTs to commit in their own transaction.
+
+    The birth_names fixture stages charts and the dashboard via session.add()
+    but does not commit. Without this, the test's first commit batches the
+    INSERTs and UPDATEs into the same Continuum transaction, causing the
+    existing version row to be updated in place instead of a new one being
+    created.
+    """
+    db.session.commit()
+
+
+class TestChartRestoreApi(SupersetTestCase):
+    """T037 — POST /api/v1/chart/<uuid>/versions/<version_uuid>/restore."""
+
+    @pytest.fixture(autouse=True)
+    def _load_data(self, load_birth_names_dashboard_with_slices):  # noqa: 
PT004, F811
+        pass
+
+    def _restore(self, chart_uuid: str, version_uuid: str) -> Any:
+        return self.client.post(
+            f"/api/v1/chart/{chart_uuid}/versions/{version_uuid}/restore"
+        )
+
+    def _list(self, chart_uuid: str) -> Any:
+        return self.client.get(f"/api/v1/chart/{chart_uuid}/versions/")
+
+    def test_restore_applies_scalar_field_from_target_version(self) -> None:
+        """Restoring version 0 puts the slice_name back to its pre-edit value
+        and appends a new version entry."""
+        _persist_fixture_state()
+        chart: Slice = (
+            db.session.query(Slice).filter(Slice.slice_name == "Girls").first()
+        )
+        assert chart is not None
+        chart_uuid = str(chart.uuid)
+        original_name = chart.slice_name
+
+        # Produce two additional saves so version history is 0/1/2.
+        chart.slice_name = "Girls v1"
+        db.session.commit()
+        chart.slice_name = "Girls v2"
+        db.session.commit()
+
+        self.login(ADMIN_USERNAME)
+        rv_list = self._list(chart_uuid)
+        assert rv_list.status_code == 200
+        listing = _json.loads(rv_list.data.decode("utf-8"))
+        initial_count = listing["count"]
+        assert initial_count >= 3
+        target_uuid = listing["result"][0]["version_uuid"]
+
+        # Restore to the first version (the original "Girls" name).
+        rv = self._restore(chart_uuid, target_uuid)
+        assert rv.status_code == 200, rv.data
+
+        # Live state matches the restored snapshot.
+        db.session.expire_all()
+        chart = db.session.query(Slice).filter(Slice.uuid == chart.uuid).one()
+        assert chart.slice_name == original_name
+
+        # A new version row was recorded (non-destructive).
+        rv_list2 = self._list(chart_uuid)
+        body = _json.loads(rv_list2.data.decode("utf-8"))
+        assert body["count"] == initial_count + 1
+
+        # Cleanup
+        chart.slice_name = original_name
+        db.session.commit()
+
+    def test_restore_returns_404_for_unknown_uuid(self) -> None:
+        self.login(ADMIN_USERNAME)
+        rv = self._restore(
+            "00000000-0000-0000-0000-000000000000",
+            "00000000-0000-0000-0000-000000000001",
+        )
+        assert rv.status_code == 404
+
+    def test_restore_returns_404_for_unknown_version_uuid(self) -> None:
+        _persist_fixture_state()
+        chart: Slice = (
+            db.session.query(Slice).filter(Slice.slice_name == "Boys").first()
+        )
+        assert chart is not None
+        self.login(ADMIN_USERNAME)
+        rv = self._restore(str(chart.uuid), 
"00000000-0000-0000-0000-000000000099")
+        assert rv.status_code == 404
+
+    def test_restore_returns_400_for_invalid_entity_uuid(self) -> None:
+        self.login(ADMIN_USERNAME)
+        rv = self._restore("not-a-uuid", 
"00000000-0000-0000-0000-000000000001")
+        assert rv.status_code == 400
+
+    def test_restore_returns_400_for_invalid_version_uuid(self) -> None:
+        _persist_fixture_state()
+        chart: Slice = (
+            db.session.query(Slice).filter(Slice.slice_name == "Boys").first()
+        )
+        assert chart is not None
+        self.login(ADMIN_USERNAME)
+        rv = self._restore(str(chart.uuid), "not-a-uuid")
+        assert rv.status_code == 400
+
+    def test_get_version_returns_historical_snapshot(self) -> None:
+        """GET /versions/<uuid>/ returns the chart's fields at that version
+        without modifying live state."""
+        _persist_fixture_state()
+        chart: Slice = (
+            db.session.query(Slice).filter(Slice.slice_name == "Girls").first()
+        )
+        assert chart is not None
+        chart_uuid = str(chart.uuid)
+        original_name = chart.slice_name
+
+        chart.slice_name = "Girls (v1)"
+        db.session.commit()
+
+        self.login(ADMIN_USERNAME)
+        listing = _json.loads(self._list(chart_uuid).data.decode("utf-8"))
+        assert listing["count"] >= 2
+        # The earliest entry should still hold the original slice_name.
+        first_version_uuid = listing["result"][0]["version_uuid"]
+
+        rv = self.client.get(
+            f"/api/v1/chart/{chart_uuid}/versions/{first_version_uuid}/"
+        )
+        assert rv.status_code == 200, rv.data
+        body = _json.loads(rv.data.decode("utf-8"))["result"]
+        assert body["slice_name"] == original_name
+        assert body["_version"]["version_uuid"] == first_version_uuid
+        assert body["_version"]["version_number"] == 0
+        # Live row unchanged.
+        db.session.expire_all()
+        live = db.session.query(Slice).filter(Slice.uuid == chart.uuid).one()
+        assert live.slice_name == "Girls (v1)"
+
+        # Cleanup
+        live.slice_name = original_name
+        db.session.commit()
+
+    def test_get_version_returns_404_for_unknown_entity(self) -> None:
+        self.login(ADMIN_USERNAME)
+        rv = self.client.get(
+            "/api/v1/chart/00000000-0000-0000-0000-000000000000"
+            "/versions/00000000-0000-0000-0000-000000000001/"
+        )
+        assert rv.status_code == 404
+
+    def test_get_version_returns_400_for_invalid_uuid(self) -> None:
+        self.login(ADMIN_USERNAME)
+        rv = self.client.get(
+            
"/api/v1/chart/not-a-uuid/versions/00000000-0000-0000-0000-000000000001/"
+        )
+        assert rv.status_code == 400
+
+    def test_restore_stamps_changed_by_with_restoring_user(self) -> None:
+        """After a restore, changed_by_fk on the live entity must point at
+        the restoring user (not at whoever authored the version being
+        restored). created_by_fk stays unchanged. The new version row
+        produced by the restore also carries the restoring user in its
+        changed_by metadata.
+        """
+        from superset.daos.version import derive_version_uuid
+
+        _persist_fixture_state()
+        self.login(ADMIN_USERNAME)
+        admin_id = self.get_user(ADMIN_USERNAME).id
+        chart: Slice = (
+            db.session.query(Slice).filter(Slice.slice_name == "Girls").first()
+        )
+        assert chart is not None
+        chart_id = chart.id
+        chart_uuid = str(chart.uuid)
+        entity_uuid = chart.uuid
+        original_name = chart.slice_name
+        original_created_by = chart.created_by_fk
+        before_changed_on = chart.changed_on
+
+        # Produce a second version to restore to.
+        chart.slice_name = "Girls v1"
+        db.session.commit()
+
+        ver_cls = version_class(Slice)
+        first_tx = (
+            db.session.query(ver_cls.transaction_id)
+            .filter(ver_cls.id == chart_id)
+            .order_by(ver_cls.transaction_id.asc())
+            .limit(1)
+            .scalar()
+        )
+        assert first_tx is not None
+        target_uuid = str(derive_version_uuid(entity_uuid, first_tx))
+
+        rv = self.client.post(
+            f"/api/v1/chart/{chart_uuid}/versions/{target_uuid}/restore"
+        )
+        assert rv.status_code == 200, rv.data
+
+        db.session.expire_all()
+        chart = db.session.query(Slice).filter(Slice.id == chart_id).one()
+
+        # Live entity checks.
+        assert chart.slice_name == original_name
+        assert chart.created_by_fk == original_created_by
+        assert chart.changed_by_fk == admin_id, (
+            f"Expected changed_by_fk to be restoring user id={admin_id}, "
+            f"got {chart.changed_by_fk}"
+        )
+        if before_changed_on is not None and chart.changed_on is not None:
+            assert chart.changed_on >= before_changed_on
+
+        # The new version row produced by the restore must attribute the
+        # change to the restoring user.
+        rv_list = self.client.get(f"/api/v1/chart/{chart_uuid}/versions/")
+        assert rv_list.status_code == 200
+        body = _json.loads(rv_list.data.decode("utf-8"))
+        latest_entry = body["result"][-1]
+        assert latest_entry["changed_by"] is not None, (
+            "New version row should have a changed_by"
+        )
+        assert latest_entry["changed_by"]["id"] == admin_id
+
+        # Cleanup
+        chart.slice_name = original_name
+        db.session.commit()
+
+    def test_put_response_returns_old_and_new_version_numbers(self) -> None:
+        """PUT /api/v1/chart/<id> response must include old_version and
+        new_version matching the list-versions ordering."""
+        _persist_fixture_state()
+        chart: Slice = (
+            db.session.query(Slice).filter(Slice.slice_name == "Girls").first()
+        )
+        assert chart is not None
+        chart_id = chart.id
+        original_name = chart.slice_name
+
+        ver_cls = version_class(Slice)
+        count_before = db.session.query(ver_cls).filter(ver_cls.id == 
chart_id).count()
+        expected_old = count_before - 1 if count_before > 0 else None
+
+        self.login(ADMIN_USERNAME)
+        rv = self.client.put(
+            f"/api/v1/chart/{chart_id}",
+            json={"slice_name": "put-response-version-test"},
+        )
+        assert rv.status_code == 200, rv.data
+        body = _json.loads(rv.data.decode("utf-8"))
+        assert body["id"] == chart_id
+        assert body["old_version"] == expected_old
+        assert body["new_version"] is not None
+        assert "old_transaction_id" in body
+        assert "new_transaction_id" in body
+        if body["old_transaction_id"] is not None:
+            assert body["new_transaction_id"] != body["old_transaction_id"]
+
+        # Cleanup
+        chart = db.session.query(Slice).filter(Slice.id == chart_id).one()
+        chart.slice_name = original_name
+        db.session.commit()
+
+    def test_restore_denies_non_editor_with_write_permission(self) -> None:
+        """A user holding can_write on Chart but who is not an editor of
+        THIS chart gets 403 from the command's ``raise_for_editorship``
+        branch — the interesting case route-level ``@protect()`` cannot
+        catch."""
+        from superset.daos.version import derive_version_uuid
+        from tests.integration_tests.constants import ALPHA_USERNAME
+
+        _persist_fixture_state()
+        chart: Slice = (
+            db.session.query(Slice).filter(Slice.slice_name == "Girls").first()
+        )
+        assert chart is not None
+        # Ensure alpha is not an editor of the fixture chart.
+        alpha = self.get_user(ALPHA_USERNAME)
+        assert alpha not in chart.editors
+
+        ver_cls = version_class(Slice)
+        first_tx = (
+            db.session.query(ver_cls.transaction_id)
+            .filter(ver_cls.id == chart.id)
+            .order_by(ver_cls.transaction_id.asc())
+            .limit(1)
+            .scalar()
+        )
+        assert first_tx is not None
+        target_uuid = str(derive_version_uuid(chart.uuid, first_tx))
+
+        self.login(ALPHA_USERNAME)
+        rv = self._restore(str(chart.uuid), target_uuid)
+        assert rv.status_code == 403, rv.data
+        db.session.refresh(chart)
+        assert chart.slice_name == "Girls"
+
+    def test_restore_returns_404_when_capture_disabled(self) -> None:
+        """With ENABLE_VERSIONING_CAPTURE off, the restore route is inert:
+        Continuum's write listeners are detached, so a revert would mutate
+        the live entity with no new version row — a destructive, untracked
+        write. The command refuses with 404 before touching anything."""
+        _persist_fixture_state()
+        chart: Slice = (
+            db.session.query(Slice).filter(Slice.slice_name == "Girls").first()
+        )
+        assert chart is not None
+        original_name = chart.slice_name
+
+        self.login(ADMIN_USERNAME)
+        listing = _json.loads(self._list(str(chart.uuid)).data.decode("utf-8"))
+        target_uuid = listing["result"][0]["version_uuid"]
+
+        self.app.config["ENABLE_VERSIONING_CAPTURE"] = False
+        try:
+            rv = self._restore(str(chart.uuid), target_uuid)
+        finally:
+            self.app.config["ENABLE_VERSIONING_CAPTURE"] = True
+        assert rv.status_code == 404, rv.data
+
+        db.session.expire_all()
+        live = db.session.query(Slice).filter(Slice.id == chart.id).one()
+        assert live.slice_name == original_name
+
+    def test_restore_returns_404_for_other_entitys_version_uuid(self) -> None:
+        """A version_uuid belonging to a DIFFERENT chart must not resolve:
+        version identity is (entity_uuid, transaction), so entity A's
+        version_uuid presented under entity B's path is a 404."""
+        from superset.daos.version import derive_version_uuid
+
+        _persist_fixture_state()
+        girls: Slice = (
+            db.session.query(Slice).filter(Slice.slice_name == "Girls").first()
+        )
+        boys: Slice = db.session.query(Slice).filter(Slice.slice_name == 
"Boys").first()
+        assert girls is not None
+        assert boys is not None
+
+        ver_cls = version_class(Slice)
+        boys_tx = (
+            db.session.query(ver_cls.transaction_id)
+            .filter(ver_cls.id == boys.id)
+            .order_by(ver_cls.transaction_id.asc())
+            .limit(1)
+            .scalar()
+        )
+        assert boys_tx is not None
+        boys_version_uuid = str(derive_version_uuid(boys.uuid, boys_tx))
+
+        self.login(ADMIN_USERNAME)
+        rv = self._restore(str(girls.uuid), boys_version_uuid)
+        assert rv.status_code == 404, rv.data
+
+    def test_restore_stamps_action_kind_restore_on_transaction(self) -> None:
+        """The restoring commit's version_transaction row must carry
+        ``action_kind='restore'`` so the activity feed renders it as a
+        restore, not an ordinary save (contract in versioning/changes)."""
+        from sqlalchemy_continuum import versioning_manager
+
+        from superset.daos.version import derive_version_uuid
+
+        _persist_fixture_state()
+        chart: Slice = (
+            db.session.query(Slice).filter(Slice.slice_name == "Girls").first()
+        )
+        assert chart is not None
+        chart_id = chart.id
+        original_name = chart.slice_name
+
+        chart.slice_name = "Girls action-kind v1"
+        db.session.commit()
+
+        ver_cls = version_class(Slice)
+        first_tx = (
+            db.session.query(ver_cls.transaction_id)
+            .filter(ver_cls.id == chart_id)
+            .order_by(ver_cls.transaction_id.asc())
+            .limit(1)
+            .scalar()
+        )
+        target_uuid = str(derive_version_uuid(chart.uuid, first_tx))
+
+        self.login(ADMIN_USERNAME)
+        rv = self._restore(str(chart.uuid), target_uuid)
+        assert rv.status_code == 200, rv.data
+
+        latest_tx = (
+            db.session.query(ver_cls.transaction_id)
+            .filter(ver_cls.id == chart_id)
+            .order_by(ver_cls.transaction_id.desc())
+            .limit(1)
+            .scalar()
+        )
+        tx_tbl = versioning_manager.transaction_cls.__table__
+        action_kind = (
+            db.session.execute(tx_tbl.select().where(tx_tbl.c.id == latest_tx))
+            .mappings()
+            .one()["action_kind"]
+        )
+        assert action_kind == "restore"
+
+        # Cleanup
+        db.session.expire_all()
+        chart = db.session.query(Slice).filter(Slice.id == chart_id).one()
+        chart.slice_name = original_name
+        db.session.commit()
diff --git a/tests/integration_tests/dashboards/version_restore_tests.py 
b/tests/integration_tests/dashboards/version_restore_tests.py
new file mode 100644
index 00000000000..93bc71fdef6
--- /dev/null
+++ b/tests/integration_tests/dashboards/version_restore_tests.py
@@ -0,0 +1,418 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Integration tests for Dashboard version restore.
+
+Covers POST /api/v1/dashboard/<uuid>/versions/<version_uuid>/restore:
+the non-destructive revert applies the target snapshot (including
+reattaching a chart removed after that snapshot) and returns the
+documented 404s for unknown entity/version UUIDs.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+import pytest
+import sqlalchemy as sa
+from sqlalchemy_continuum import version_class
+
+from superset.extensions import db
+from superset.models.dashboard import Dashboard
+from superset.utils import json as _json
+from tests.integration_tests.base_tests import SupersetTestCase
+from tests.integration_tests.constants import ADMIN_USERNAME
+from tests.integration_tests.fixtures.birth_names_dashboard import (  # noqa: 
F401
+    load_birth_names_dashboard_with_slices,
+    load_birth_names_data,
+)
+
+
+def _get_version_rows(dashboard: Dashboard) -> list[Any]:
+    ver_cls = version_class(Dashboard)
+    return (
+        db.session.query(ver_cls)
+        .filter(ver_cls.id == dashboard.id)
+        .order_by(ver_cls.transaction_id.asc())
+        .all()
+    )
+
+
+def _persist_fixture_state() -> None:
+    """Force fixture's pending INSERTs to commit in their own transaction.
+
+    The birth_names fixture stages charts and the dashboard via session.add()
+    but does not commit. Without this, the test's first commit batches the
+    INSERTs and UPDATEs into the same Continuum transaction, causing the
+    existing version row to be updated in place instead of a new one being
+    created.
+    """
+    db.session.commit()
+
+
+class TestDashboardRestoreApi(SupersetTestCase):
+    """T038 — POST /api/v1/dashboard/<uuid>/versions/<version_uuid>/restore."""
+
+    @pytest.fixture(autouse=True)
+    def _load_data(self, load_birth_names_dashboard_with_slices):  # noqa: 
PT004, F811
+        pass
+
+    def _restore(self, dashboard_uuid: str, version_uuid: str) -> Any:
+        return self.client.post(
+            
f"/api/v1/dashboard/{dashboard_uuid}/versions/{version_uuid}/restore"
+        )
+
+    def test_restore_applies_scalar_field(self) -> None:
+        """Restore a dashboard title edit."""
+        from superset.daos.version import derive_version_uuid
+
+        _persist_fixture_state()
+        dashboard: Dashboard = (
+            db.session.query(Dashboard)
+            .filter(Dashboard.dashboard_title == "USA Births Names")
+            .first()
+        )
+        assert dashboard is not None
+        dashboard_uuid = str(dashboard.uuid)
+        original_title = dashboard.dashboard_title
+        dashboard_id = dashboard.id
+        entity_uuid = dashboard.uuid
+
+        # Make two more edits so we have a known non-trivial history to
+        # navigate: [initial, v1, v2].
+        dashboard.dashboard_title = "USA Births Names v1"
+        db.session.commit()
+        dashboard.dashboard_title = "USA Births Names v2"
+        db.session.commit()
+
+        ver_cls = version_class(Dashboard)
+        rows = (
+            db.session.query(
+                ver_cls.transaction_id,
+                ver_cls.operation_type,
+                ver_cls.dashboard_title,
+                ver_cls.end_transaction_id,
+            )
+            .filter(ver_cls.id == dashboard_id)
+            .order_by(ver_cls.transaction_id.asc())
+            .all()
+        )
+        # Find the version whose snapshot has the original title.
+        target_row = next(
+            (row for row in rows if row.dashboard_title == original_title),
+            None,
+        )
+        assert target_row is not None, (
+            f"Expected at least one version row with original title; 
rows={rows}"
+        )
+        target_uuid = str(derive_version_uuid(entity_uuid, 
target_row.transaction_id))
+
+        self.login(ADMIN_USERNAME)
+        rv = self._restore(dashboard_uuid, target_uuid)
+        assert rv.status_code == 200, rv.data
+
+        db.session.expire_all()
+        dashboard = (
+            db.session.query(Dashboard).filter(Dashboard.id == 
dashboard_id).one()
+        )
+        assert dashboard.dashboard_title == original_title, (
+            f"Restore did not revert title; rows={rows}"
+        )
+
+        # Cleanup
+        dashboard.dashboard_title = original_title
+        db.session.commit()
+
+    def test_restore_reattaches_chart_removed_after_snapshot(self) -> None:
+        """After the target snapshot is captured, detaching a chart and saving
+        must be undone by restore — the chart comes back on 
dashboard_slices."""
+        from superset.daos.version import derive_version_uuid
+
+        _persist_fixture_state()
+        dashboard: Dashboard = (
+            db.session.query(Dashboard)
+            .filter(Dashboard.dashboard_title == "USA Births Names")
+            .first()
+        )
+        assert dashboard is not None
+        dashboard_uuid = str(dashboard.uuid)
+        dashboard_id = dashboard.id
+        entity_uuid = dashboard.uuid
+
+        original_slice_ids = sorted(s.id for s in dashboard.slices)
+        assert len(original_slice_ids) >= 2, (
+            f"fixture expected to attach >= 2 charts; got {original_slice_ids}"
+        )
+        slice_to_drop = dashboard.slices[0]
+        drop_id = slice_to_drop.id
+
+        # Touch the dashboard so a snapshot row is captured at a known tx.
+        dashboard.dashboard_title = "USA Births Names — snapshot point"
+        db.session.commit()
+
+        ver_cls = version_class(Dashboard)
+        target_tx = (
+            db.session.query(ver_cls.transaction_id)
+            .filter(ver_cls.id == dashboard_id)
+            .order_by(ver_cls.transaction_id.desc())
+            .limit(1)
+            .scalar()
+        )
+        assert target_tx is not None
+        target_uuid = str(derive_version_uuid(entity_uuid, target_tx))
+
+        # Detach the chart and commit — moves history forward.
+        dashboard.slices.remove(slice_to_drop)
+        db.session.commit()
+
+        db.session.expire_all()
+        dashboard = (
+            db.session.query(Dashboard).filter(Dashboard.id == 
dashboard_id).one()
+        )
+        live_ids = {s.id for s in dashboard.slices}
+        assert drop_id not in live_ids, "pre-restore: dropped chart should be 
detached"
+
+        self.login(ADMIN_USERNAME)
+        rv = self._restore(dashboard_uuid, target_uuid)
+        assert rv.status_code == 200, rv.data
+
+        db.session.expire_all()
+        dashboard = (
+            db.session.query(Dashboard).filter(Dashboard.id == 
dashboard_id).one()
+        )
+        restored_ids = sorted(s.id for s in dashboard.slices)
+        assert restored_ids == original_slice_ids, (
+            f"restore did not re-attach chart: expected {original_slice_ids}, "
+            f"got {restored_ids}"
+        )
+
+    def test_restore_preserves_live_chart_content(self) -> None:
+        """Dashboard restore is membership-only: a member chart edited
+        AFTER the snapshot keeps its current content — charts are shared
+        entities with their own restore endpoint, so a dashboard restore
+        must never rewrite them to historical values."""
+        from superset.daos.version import derive_version_uuid
+        from superset.models.slice import Slice
+
+        _persist_fixture_state()
+        dashboard: Dashboard = (
+            db.session.query(Dashboard)
+            .filter(Dashboard.dashboard_title == "USA Births Names")
+            .first()
+        )
+        assert dashboard is not None
+        dashboard_id = dashboard.id
+        member = dashboard.slices[0]
+        member_id = member.id
+        original_chart_name = member.slice_name
+        original_title = dashboard.dashboard_title
+
+        # Snapshot point: chart still has its original name.
+        dashboard.dashboard_title = "USA Births Names — content snapshot"
+        db.session.commit()
+
+        ver_cls = version_class(Dashboard)
+        target_tx = (
+            db.session.query(ver_cls.transaction_id)
+            .filter(ver_cls.id == dashboard_id)
+            .order_by(ver_cls.transaction_id.desc())
+            .limit(1)
+            .scalar()
+        )
+        target_uuid = str(derive_version_uuid(dashboard.uuid, target_tx))
+
+        # Edit the member chart AFTER the snapshot.
+        member = db.session.query(Slice).filter(Slice.id == member_id).one()
+        member.slice_name = "edited after snapshot"
+        db.session.commit()
+
+        self.login(ADMIN_USERNAME)
+        rv = self._restore(str(dashboard.uuid), target_uuid)
+        assert rv.status_code == 200, rv.data
+
+        db.session.expire_all()
+        member = db.session.query(Slice).filter(Slice.id == member_id).one()
+        assert member.slice_name == "edited after snapshot", (
+            "dashboard restore must not rewrite live member chart content"
+        )
+
+        # Cleanup
+        member.slice_name = original_chart_name
+        dashboard = (
+            db.session.query(Dashboard).filter(Dashboard.id == 
dashboard_id).one()
+        )
+        dashboard.dashboard_title = original_title
+        db.session.commit()
+
+    def test_restore_skips_member_chart_that_no_longer_exists(self) -> None:
+        """A snapshot member whose chart row has been hard-deleted stays
+        deleted: restore succeeds, reattaches the surviving members, does
+        NOT revive the deleted chart, and says so in the response."""
+        from superset.daos.version import derive_version_uuid
+        from superset.models.slice import Slice
+
+        _persist_fixture_state()
+        dashboard: Dashboard = (
+            db.session.query(Dashboard)
+            .filter(Dashboard.dashboard_title == "USA Births Names")
+            .first()
+        )
+        assert dashboard is not None
+        dashboard_id = dashboard.id
+        original_ids = sorted(s.id for s in dashboard.slices)
+        assert len(original_ids) >= 2
+        victim = dashboard.slices[0]
+        victim_id = victim.id
+
+        # Snapshot point: victim is a member.
+        dashboard.dashboard_title = "USA Births Names — skip snapshot"
+        db.session.commit()
+
+        ver_cls = version_class(Dashboard)
+        target_tx = (
+            db.session.query(ver_cls.transaction_id)
+            .filter(ver_cls.id == dashboard_id)
+            .order_by(ver_cls.transaction_id.desc())
+            .limit(1)
+            .scalar()
+        )
+        target_uuid = str(derive_version_uuid(dashboard.uuid, target_tx))
+
+        # Detach, then hard-delete the victim via raw SQL so no live row
+        # remains (bypasses the soft-delete listener deliberately — the
+        # scenario is a purged/legacy-deleted chart).
+        dashboard.slices.remove(victim)
+        db.session.commit()
+        for assoc in ("chart_editors", "chart_viewers"):
+            db.session.execute(
+                sa.text(f"DELETE FROM {assoc} WHERE chart_id = :sid"),  # 
noqa: S608
+                {"sid": victim_id},
+            )
+        db.session.execute(
+            sa.text("DELETE FROM slices WHERE id = :sid"), {"sid": victim_id}
+        )
+        db.session.commit()
+
+        self.login(ADMIN_USERNAME)
+        rv = self._restore(str(dashboard.uuid), target_uuid)
+        assert rv.status_code == 200, rv.data
+        message = _json.loads(rv.data.decode("utf-8")).get("message", "")
+        assert "no longer exist" in message, message
+
+        db.session.expire_all()
+        dashboard = (
+            db.session.query(Dashboard).filter(Dashboard.id == 
dashboard_id).one()
+        )
+        restored_ids = sorted(s.id for s in dashboard.slices)
+        assert victim_id not in restored_ids, "deleted chart must stay deleted"
+        assert restored_ids == sorted(sid for sid in original_ids if sid != 
victim_id)
+        assert (
+            db.session.query(Slice).filter(Slice.id == 
victim_id).one_or_none() is None
+        ), "restore must not revive a hard-deleted chart"
+
+    def test_restore_denies_non_editor_with_write_permission(self) -> None:
+        """A user holding can_write on Dashboard but who is not an editor
+        of THIS dashboard gets 403 from the command's editorship check."""
+        from superset.daos.version import derive_version_uuid
+        from tests.integration_tests.constants import ALPHA_USERNAME
+
+        _persist_fixture_state()
+        dashboard: Dashboard = (
+            db.session.query(Dashboard)
+            .filter(Dashboard.dashboard_title == "USA Births Names")
+            .first()
+        )
+        assert dashboard is not None
+        alpha = self.get_user(ALPHA_USERNAME)
+        assert alpha not in dashboard.editors
+
+        ver_cls = version_class(Dashboard)
+        first_tx = (
+            db.session.query(ver_cls.transaction_id)
+            .filter(ver_cls.id == dashboard.id)
+            .order_by(ver_cls.transaction_id.asc())
+            .limit(1)
+            .scalar()
+        )
+        assert first_tx is not None
+        target_uuid = str(derive_version_uuid(dashboard.uuid, first_tx))
+
+        self.login(ALPHA_USERNAME)
+        rv = self._restore(str(dashboard.uuid), target_uuid)
+        assert rv.status_code == 403, rv.data
+        db.session.refresh(dashboard)
+        assert dashboard.dashboard_title == "USA Births Names"
+
+    def test_restore_returns_404_for_unknown_uuid(self) -> None:
+        self.login(ADMIN_USERNAME)
+        rv = self._restore(
+            "00000000-0000-0000-0000-000000000000",
+            "00000000-0000-0000-0000-000000000001",
+        )
+        assert rv.status_code == 404
+
+    def test_restore_returns_404_for_unknown_version_uuid(self) -> None:
+        _persist_fixture_state()
+        dashboard: Dashboard = (
+            db.session.query(Dashboard)
+            .filter(Dashboard.dashboard_title == "USA Births Names")
+            .first()
+        )
+        assert dashboard is not None
+        self.login(ADMIN_USERNAME)
+        rv = self._restore(str(dashboard.uuid), 
"00000000-0000-0000-0000-000000000099")
+        assert rv.status_code == 404
+
+    def test_put_response_returns_old_and_new_version_numbers(self) -> None:
+        """PUT /api/v1/dashboard/<id> response must include old_version and
+        new_version matching the list-versions ordering."""
+        _persist_fixture_state()
+        dashboard: Dashboard = (
+            db.session.query(Dashboard)
+            .filter(Dashboard.dashboard_title == "USA Births Names")
+            .first()
+        )
+        assert dashboard is not None
+        dashboard_id = dashboard.id
+        original_title = dashboard.dashboard_title
+
+        ver_cls = version_class(Dashboard)
+        count_before = (
+            db.session.query(ver_cls).filter(ver_cls.id == 
dashboard_id).count()
+        )
+        expected_old = count_before - 1 if count_before > 0 else None
+
+        self.login(ADMIN_USERNAME)
+        rv = self.client.put(
+            f"/api/v1/dashboard/{dashboard_id}",
+            json={"dashboard_title": "put-response-version-test"},
+        )
+        assert rv.status_code == 200, rv.data
+        body = _json.loads(rv.data.decode("utf-8"))
+        assert body["id"] == dashboard_id
+        assert body["old_version"] == expected_old
+        assert body["new_version"] is not None
+        assert "old_transaction_id" in body
+        assert "new_transaction_id" in body
+        if body["old_transaction_id"] is not None:
+            assert body["new_transaction_id"] != body["old_transaction_id"]
+
+        # Cleanup
+        dashboard = (
+            db.session.query(Dashboard).filter(Dashboard.id == 
dashboard_id).one()
+        )
+        dashboard.dashboard_title = original_title
+        db.session.commit()
diff --git a/tests/integration_tests/datasets/version_restore_tests.py 
b/tests/integration_tests/datasets/version_restore_tests.py
new file mode 100644
index 00000000000..f8e82baddae
--- /dev/null
+++ b/tests/integration_tests/datasets/version_restore_tests.py
@@ -0,0 +1,507 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Integration tests for Dataset (SqlaTable) version restore.
+
+Covers POST /api/v1/dataset/<uuid>/versions/<version_uuid>/restore: the
+non-destructive revert applies the target snapshot, reverts child
+column/metric edits (re-adding removed children and dropping added ones)
+in a single transaction, denies callers without write permission, and
+returns the documented 400/404 errors.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+import pytest
+import sqlalchemy as sa
+from sqlalchemy_continuum import version_class
+
+from superset.connectors.sqla.models import SqlaTable, SqlMetric, TableColumn
+from superset.extensions import db
+from superset.utils import json as _json
+from tests.integration_tests.base_tests import SupersetTestCase
+from tests.integration_tests.constants import ADMIN_USERNAME, GAMMA_USERNAME
+from tests.integration_tests.fixtures.birth_names_dashboard import (  # noqa: 
F401
+    load_birth_names_dashboard_with_slices,
+    load_birth_names_data,
+)
+
+
+def _get_table_column_version_rows(column: TableColumn) -> list[Any]:
+    ver_cls = version_class(TableColumn)
+    return (
+        db.session.query(ver_cls)
+        .filter(ver_cls.id == column.id)
+        .order_by(ver_cls.transaction_id.asc())
+        .all()
+    )
+
+
+def _get_sql_metric_version_rows(metric: SqlMetric) -> list[Any]:
+    ver_cls = version_class(SqlMetric)
+    return (
+        db.session.query(ver_cls)
+        .filter(ver_cls.id == metric.id)
+        .order_by(ver_cls.transaction_id.asc())
+        .all()
+    )
+
+
+def _get_table_version_rows(table: SqlaTable) -> list[Any]:
+    ver_cls = version_class(SqlaTable)
+    return (
+        db.session.query(ver_cls)
+        .filter(ver_cls.id == table.id)
+        .order_by(ver_cls.transaction_id.asc())
+        .all()
+    )
+
+
+def _persist_fixture_state() -> None:
+    """Force fixture's pending INSERTs to commit in their own transaction.
+
+    The birth_names fixture stages charts and the dashboard via session.add()
+    but does not commit. Without this, the test's first commit batches the
+    INSERTs and UPDATEs into the same Continuum transaction, causing the
+    existing version row to be updated in place instead of a new one being
+    created.
+    """
+    db.session.commit()
+
+
+class TestDatasetRestoreApi(SupersetTestCase):
+    """T039 — POST /api/v1/dataset/<uuid>/versions/<version_uuid>/restore."""
+
+    @pytest.fixture(autouse=True)
+    def _load_data(self, load_birth_names_dashboard_with_slices):  # noqa: 
PT004, F811
+        pass
+
+    def _restore(self, dataset_uuid: str, version_uuid: str) -> Any:
+        return self.client.post(
+            f"/api/v1/dataset/{dataset_uuid}/versions/{version_uuid}/restore"
+        )
+
+    def test_restore_applies_scalar_field(self) -> None:
+        """Restore a dataset's description edit."""
+        from superset.daos.version import derive_version_uuid
+
+        _persist_fixture_state()
+        table: SqlaTable = (
+            db.session.query(SqlaTable)
+            .filter(SqlaTable.table_name == "birth_names")
+            .first()
+        )
+        assert table is not None
+        table_uuid = str(table.uuid)
+        entity_uuid = table.uuid
+        table_id = table.id
+        original_description = table.description
+
+        # Two more edits to produce a non-trivial history.
+        table.description = "restore-test v1"
+        db.session.commit()
+        table.description = "restore-test v2"
+        db.session.commit()
+
+        ver_cls = version_class(SqlaTable)
+        rows = (
+            db.session.query(
+                ver_cls.transaction_id,
+                ver_cls.operation_type,
+                ver_cls.description,
+            )
+            .filter(ver_cls.id == table_id)
+            .order_by(ver_cls.transaction_id.asc())
+            .all()
+        )
+        target_row = next(
+            (row for row in rows if row.description == original_description),
+            None,
+        )
+        assert target_row is not None, (
+            f"No version with original description; rows={rows}"
+        )
+        target_uuid = str(derive_version_uuid(entity_uuid, 
target_row.transaction_id))
+
+        self.login(ADMIN_USERNAME)
+        rv = self._restore(table_uuid, target_uuid)
+        assert rv.status_code == 200, rv.data
+
+        db.session.expire_all()
+        table = db.session.query(SqlaTable).filter(SqlaTable.id == 
table_id).one()
+        assert table.description == original_description
+
+        # Cleanup
+        table.description = original_description
+        db.session.commit()
+
+    def test_restore_with_column_edits_reverts_columns(self) -> None:
+        """After editing a column's description, restoring an earlier version
+        reverts the column."""
+        from superset.daos.version import derive_version_uuid
+
+        _persist_fixture_state()
+        table: SqlaTable = (
+            db.session.query(SqlaTable)
+            .filter(SqlaTable.table_name == "birth_names")
+            .first()
+        )
+        assert table is not None
+        table_uuid = str(table.uuid)
+        entity_uuid = table.uuid
+        table_id = table.id
+
+        col = table.columns[0]
+        col_name = col.column_name
+        original_col_description = col.description
+
+        # Snapshot target version before our column edit.
+        ver_cls = version_class(SqlaTable)
+        last_tx = (
+            db.session.query(ver_cls.transaction_id)
+            .filter(ver_cls.id == table_id)
+            .order_by(ver_cls.transaction_id.desc())
+            .limit(1)
+            .scalar()
+        )
+        assert last_tx is not None
+        target_uuid = str(derive_version_uuid(entity_uuid, last_tx))
+
+        col.description = "restore-test column edit"
+        db.session.commit()
+
+        self.login(ADMIN_USERNAME)
+        rv = self._restore(table_uuid, target_uuid)
+        assert rv.status_code == 200, rv.data
+
+        # JSON-snapshot restore reassigns child PKs, so look up by natural
+        # key (column_name) rather than the old id.
+        db.session.expire_all()
+        col = (
+            db.session.query(TableColumn)
+            .filter(TableColumn.table_id == table_id)
+            .filter(TableColumn.column_name == col_name)
+            .one()
+        )
+        assert col.description == original_col_description
+
+        # Cleanup
+        col.description = original_col_description
+        db.session.commit()
+
+    def test_restore_adds_back_removed_column_and_drops_added_one(self) -> 
None:
+        """After a snapshot is taken, removing an existing column and adding
+        a new one, restoring the snapshot must undo both operations."""
+        from superset.daos.version import derive_version_uuid
+
+        _persist_fixture_state()
+        table: SqlaTable = (
+            db.session.query(SqlaTable)
+            .filter(SqlaTable.table_name == "birth_names")
+            .first()
+        )
+        assert table is not None
+        table_id = table.id
+        table_uuid = str(table.uuid)
+        entity_uuid = table.uuid
+
+        original_col_names = sorted(c.column_name for c in table.columns)
+        removed_name = table.columns[0].column_name
+
+        # Capture a snapshot tx point by touching the dataset.
+        table.description = "snapshot before column-swap"
+        db.session.commit()
+
+        ver_cls = version_class(SqlaTable)
+        target_tx = (
+            db.session.query(ver_cls.transaction_id)
+            .filter(ver_cls.id == table_id)
+            .order_by(ver_cls.transaction_id.desc())
+            .limit(1)
+            .scalar()
+        )
+        assert target_tx is not None
+        target_uuid = str(derive_version_uuid(entity_uuid, target_tx))
+
+        # Remove a column, add a new one, commit (moves history forward).
+        db.session.delete(table.columns[0])
+        db.session.add(
+            TableColumn(
+                table_id=table_id,
+                column_name="__restore_test_calc__",
+                expression="1",
+            )
+        )
+        db.session.commit()
+
+        assert removed_name not in {c.column_name for c in table.columns}
+        assert "__restore_test_calc__" in {c.column_name for c in 
table.columns}
+
+        self.login(ADMIN_USERNAME)
+        rv = self._restore(table_uuid, target_uuid)
+        assert rv.status_code == 200, rv.data
+
+        db.session.expire_all()
+        table = db.session.query(SqlaTable).filter(SqlaTable.id == 
table_id).one()
+        restored_names = sorted(c.column_name for c in table.columns)
+        assert restored_names == original_col_names
+
+    def test_restore_emits_full_child_diff_in_one_transaction(self) -> None:
+        """A restore that re-adds one column and drops another MUST write
+        *both* change records under the same transaction. Under the prior
+        per-relation flush loop the first flush emitted only the
+        easier-to-detect change (the modification of a surviving
+        column), the listener's tx-dedup guard then suppressed the
+        second pass, and the addition record was silently lost from
+        ``version_changes`` — the dropdown rendered the restore as an
+        empty "Baseline" entry. Locks in the single-flush restore
+        behavior in ``VersionDAO.restore_version``.
+        """
+        from superset.daos.version import derive_version_uuid
+        from superset.versioning.changes import version_changes_table
+
+        _persist_fixture_state()
+        table: SqlaTable = (
+            db.session.query(SqlaTable)
+            .filter(SqlaTable.table_name == "birth_names")
+            .first()
+        )
+        assert table is not None
+        table_id = table.id
+        table_uuid = str(table.uuid)
+        entity_uuid = table.uuid
+        removed_name = table.columns[0].column_name
+        added_name = "__restore_full_diff_test__"
+
+        # Snapshot point captures the baseline.
+        table.description = "snapshot before full-diff column swap"
+        db.session.commit()
+
+        ver_cls = version_class(SqlaTable)
+        target_tx = (
+            db.session.query(ver_cls.transaction_id)
+            .filter(ver_cls.id == table_id)
+            .order_by(ver_cls.transaction_id.desc())
+            .limit(1)
+            .scalar()
+        )
+        assert target_tx is not None
+        target_uuid = str(derive_version_uuid(entity_uuid, target_tx))
+
+        db.session.delete(table.columns[0])
+        db.session.add(
+            TableColumn(table_id=table_id, column_name=added_name, 
expression="1")
+        )
+        db.session.commit()
+
+        self.login(ADMIN_USERNAME)
+        rv = self._restore(table_uuid, target_uuid)
+        assert rv.status_code == 200, rv.data
+        db.session.expire_all()
+
+        restore_tx = (
+            db.session.query(ver_cls.transaction_id)
+            .filter(ver_cls.id == table_id)
+            .order_by(ver_cls.transaction_id.desc())
+            .limit(1)
+            .scalar()
+        )
+        rows = (
+            db.session.connection()
+            .execute(
+                sa.select(
+                    version_changes_table.c.kind,
+                    version_changes_table.c.path,
+                ).where(
+                    version_changes_table.c.transaction_id == restore_tx,
+                    version_changes_table.c.entity_kind == "dataset",
+                    version_changes_table.c.entity_id == table_id,
+                )
+            )
+            .all()
+        )
+        paths = {tuple(row.path) for row in rows}
+        assert ("columns", added_name) in paths, (
+            f"restore tx {restore_tx} did not emit removal record for "
+            f"the added-then-restored-away column {added_name!r}; "
+            f"observed paths={paths}"
+        )
+        assert ("columns", removed_name) in paths, (
+            f"restore tx {restore_tx} did not emit addition record for "
+            f"the deleted-then-restored column {removed_name!r}; "
+            f"observed paths={paths}"
+        )
+
+    def test_restore_returns_404_for_unknown_uuid(self) -> None:
+        self.login(ADMIN_USERNAME)
+        rv = self._restore(
+            "00000000-0000-0000-0000-000000000000",
+            "00000000-0000-0000-0000-000000000001",
+        )
+        assert rv.status_code == 404
+
+    def test_restore_returns_404_for_unknown_version_uuid(self) -> None:
+        _persist_fixture_state()
+        table: SqlaTable = (
+            db.session.query(SqlaTable)
+            .filter(SqlaTable.table_name == "birth_names")
+            .first()
+        )
+        assert table is not None
+        self.login(ADMIN_USERNAME)
+        rv = self._restore(str(table.uuid), 
"00000000-0000-0000-0000-000000000099")
+        assert rv.status_code == 404
+
+    def test_restore_returns_400_for_invalid_entity_uuid(self) -> None:
+        self.login(ADMIN_USERNAME)
+        rv = self._restore("not-a-uuid", 
"00000000-0000-0000-0000-000000000001")
+        assert rv.status_code == 400
+
+    def test_restore_returns_400_for_invalid_version_uuid(self) -> None:
+        _persist_fixture_state()
+        table: SqlaTable = (
+            db.session.query(SqlaTable)
+            .filter(SqlaTable.table_name == "birth_names")
+            .first()
+        )
+        assert table is not None
+        self.login(ADMIN_USERNAME)
+        rv = self._restore(str(table.uuid), "not-a-uuid")
+        assert rv.status_code == 400
+
+    def test_get_version_returns_historical_snapshot_with_children(self) -> 
None:
+        """GET /versions/<uuid>/ on a dataset returns scalar fields and
+        reconstructed columns/metrics, without modifying live state."""
+        from superset.daos.version import derive_version_uuid
+
+        _persist_fixture_state()
+        table: SqlaTable = (
+            db.session.query(SqlaTable)
+            .filter(SqlaTable.table_name == "birth_names")
+            .first()
+        )
+        assert table is not None
+        table_id = table.id
+        table_uuid = str(table.uuid)
+        entity_uuid = table.uuid
+        original_description = table.description
+        original_col_names = sorted(c.column_name for c in table.columns)
+
+        # Capture a snapshot point now; make a change after.
+        ver_cls = version_class(SqlaTable)
+        target_tx = (
+            db.session.query(ver_cls.transaction_id)
+            .filter(ver_cls.id == table_id)
+            .order_by(ver_cls.transaction_id.desc())
+            .limit(1)
+            .scalar()
+        )
+        assert target_tx is not None
+        target_uuid = str(derive_version_uuid(entity_uuid, target_tx))
+
+        table.description = "edited after snapshot"
+        db.session.commit()
+
+        self.login(ADMIN_USERNAME)
+        rv = 
self.client.get(f"/api/v1/dataset/{table_uuid}/versions/{target_uuid}/")
+        assert rv.status_code == 200, rv.data
+        body = _json.loads(rv.data.decode("utf-8"))["result"]
+
+        # Scalar fields reflect the snapshot, not the live edit.
+        assert body["description"] == original_description
+        assert body["_version"]["version_uuid"] == target_uuid
+
+        # Columns list matches original set.
+        snapshot_col_names = sorted(c["column_name"] for c in body["columns"])
+        assert snapshot_col_names == original_col_names
+
+        # Metrics reconstructed.
+        assert isinstance(body["metrics"], list)
+        assert all("metric_name" in m for m in body["metrics"])
+
+        # Live row remains in its edited state.
+        db.session.expire_all()
+        live = db.session.query(SqlaTable).filter(SqlaTable.id == 
table_id).one()
+        assert live.description == "edited after snapshot"
+
+        # Cleanup
+        live.description = original_description
+        db.session.commit()
+
+    def test_put_response_returns_old_and_new_version_numbers(self) -> None:
+        """PUT /api/v1/dataset/<id> should include old_version and new_version
+        fields that match the list-versions endpoint's version_number 
values."""
+        _persist_fixture_state()
+        table: SqlaTable = (
+            db.session.query(SqlaTable)
+            .filter(SqlaTable.table_name == "birth_names")
+            .first()
+        )
+        assert table is not None
+        table_id = table.id
+        original_description = table.description
+
+        ver_cls = version_class(SqlaTable)
+        count_before = db.session.query(ver_cls).filter(ver_cls.id == 
table_id).count()
+        expected_old = count_before - 1 if count_before > 0 else None
+
+        self.login(ADMIN_USERNAME)
+        rv = self.client.put(
+            f"/api/v1/dataset/{table_id}",
+            json={"description": "version-number response test"},
+        )
+        assert rv.status_code == 200, rv.data
+        body = _json.loads(rv.data.decode("utf-8"))
+        assert body["id"] == table_id
+        assert "old_version" in body
+        assert "new_version" in body
+        assert "old_transaction_id" in body
+        assert "new_transaction_id" in body
+        assert body["old_version"] == expected_old
+        # new_version points to the live row post-commit. It is usually
+        # old_version + 1, but can equal old_version when retention pruning
+        # removed an older closed row in the same commit.
+        assert body["new_version"] is not None
+        assert body["new_version"] >= 0
+        # Transaction ids are stable identifiers, so a successful update
+        # always produces a new_transaction_id distinct from the previous
+        # one (when old_transaction_id is known).
+        if body["old_transaction_id"] is not None:
+            assert body["new_transaction_id"] != body["old_transaction_id"]
+
+        # Cleanup
+        table = db.session.query(SqlaTable).filter(SqlaTable.id == 
table_id).one()
+        table.description = original_description
+        db.session.commit()
+
+    def test_restore_denies_without_write_permission(self) -> None:
+        """Gamma is read-only on Dataset — 403 on restore."""
+        _persist_fixture_state()
+        table: SqlaTable = (
+            db.session.query(SqlaTable)
+            .filter(SqlaTable.table_name == "birth_names")
+            .first()
+        )
+        assert table is not None
+        table_uuid = str(table.uuid)
+
+        self.login(GAMMA_USERNAME)
+        rv = self._restore(table_uuid, "00000000-0000-0000-0000-000000000001")
+        assert rv.status_code == 403
+        db.session.refresh(table)
+        assert table.table_name == "birth_names"
diff --git a/tests/unit_tests/versioning/test_restore.py 
b/tests/unit_tests/versioning/test_restore.py
new file mode 100644
index 00000000000..4e1c2144426
--- /dev/null
+++ b/tests/unit_tests/versioning/test_restore.py
@@ -0,0 +1,133 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Unit-level coverage for the version-restore engine control flow.
+
+The happy path is exercised end-to-end in the per-entity integration
+suites (``version_restore_tests.py``); here we pin the cheap, DB-free
+guard branches with mocks: unknown entity, missing target transaction,
+DELETE-row target, unregistered model, and the ``single_flush_scope``
+flush contract.
+"""
+
+from __future__ import annotations
+
+from unittest.mock import MagicMock, patch
+from uuid import UUID
+
+import pytest
+
+from superset.versioning.baseline import OPERATION_DELETE
+from superset.versioning.restore import restore_version
+from superset.versioning.utils import single_flush_scope
+
+_UUID = UUID("00000000-0000-0000-0000-000000000000")
+
+
+@patch("superset.versioning.restore.find_active_by_uuid", return_value=None)
+def test_restore_version_returns_none_for_unknown_entity(mock_find) -> None:
+    """Unknown entity UUID → engine returns None (caller raises 404)."""
+    result = restore_version(MagicMock(__name__="Dashboard"), _UUID, 123)
+    assert result is None
+    mock_find.assert_called_once()
+
+
+def _engine_with_target(target: object) -> MagicMock:
+    """A db.session mock whose version query resolves to *target*."""
+    session = MagicMock()
+    session.query.return_value.filter.return_value.one_or_none.return_value = 
target
+    return session
+
+
+@patch("superset.versioning.restore.version_class")
+@patch("superset.versioning.restore.db")
+def test_restore_version_returns_none_for_missing_transaction(
+    mock_db, mock_version_class
+) -> None:
+    """No version row at the resolved transaction_id → None (404), e.g.
+    the row was retention-pruned between resolve and restore."""
+    mock_db.session = _engine_with_target(None)
+    result = restore_version(
+        MagicMock(__name__="Slice"), _UUID, 123, entity=MagicMock(id=1, 
uuid=_UUID)
+    )
+    assert result is None
+
+
+@patch("superset.versioning.restore.version_class")
+@patch("superset.versioning.restore.db")
+def test_restore_version_refuses_delete_row_target(mock_db, 
mock_version_class) -> None:
+    """A DELETE version row is never a valid target: Continuum's Reverter
+    would delete the live entity and report success. Engine treats it as
+    not-found."""
+    target = MagicMock(operation_type=OPERATION_DELETE)
+    mock_db.session = _engine_with_target(target)
+    result = restore_version(
+        MagicMock(__name__="Slice"), _UUID, 123, entity=MagicMock(id=1, 
uuid=_UUID)
+    )
+    assert result is None
+    target.revert.assert_not_called()
+
+
+@patch("superset.versioning.restore.version_class")
+@patch("superset.versioning.restore.db")
+def test_restore_version_fails_closed_for_unregistered_model(
+    mock_db, mock_version_class
+) -> None:
+    """An unregistered model must raise, not silently restore without its
+    child relations (mirrors _RAISE_FOR_ACCESS_KWARG's fail-closed
+    dispatch)."""
+    mock_db.session = _engine_with_target(MagicMock(operation_type=0))
+    with pytest.raises(LookupError, match="SomeNewModel"):
+        restore_version(
+            MagicMock(__name__="SomeNewModel"),
+            _UUID,
+            123,
+            entity=MagicMock(id=1, uuid=_UUID),
+        )
+
+
+@patch("superset.versioning.restore.version_class")
+@patch("superset.versioning.restore.db")
+def test_restore_version_rejects_entity_uuid_mismatch(
+    mock_db, mock_version_class
+) -> None:
+    """A preloaded *entity* must be the row *entity_uuid* names. If they
+    disagree the engine would restore one entity while the caller logs
+    another, so it raises instead of guessing."""
+    mock_db.session = _engine_with_target(MagicMock(operation_type=0))
+    other_uuid = UUID("00000000-0000-0000-0000-0000000000ff")
+    with pytest.raises(ValueError, match="does not match entity_uuid"):
+        restore_version(
+            MagicMock(__name__="Slice"),
+            _UUID,
+            123,
+            entity=MagicMock(id=1, uuid=other_uuid),
+        )
+
+
+def test_single_flush_scope_flushes_once_on_clean_exit() -> None:
+    session = MagicMock()
+    with single_flush_scope(session):
+        session.flush.assert_not_called()
+    session.flush.assert_called_once()
+
+
+def test_single_flush_scope_skips_flush_on_exception() -> None:
+    session = MagicMock()
+    with pytest.raises(RuntimeError):
+        with single_flush_scope(session):
+            raise RuntimeError("boom")
+    session.flush.assert_not_called()

Reply via email to