Skip to content

RevisionDatabaseAdapter

RevisionDatabaseAdapter

Bases: DatabaseAdapter

Snapshots the pre-write entity state into <kind>_revision.

revision_fields (skel class var) gates which changes create a revision; ["*"] (the default) reacts to any change. Each snapshot copies the old entity verbatim plus origin_key, revision_index (per-origin counter), revision_date and revision_change_list.

Source code in src/viur/revision/adapter.py
class RevisionDatabaseAdapter(DatabaseAdapter):
    """Snapshots the pre-write entity state into ``<kind>_revision``.

    ``revision_fields`` (skel class var) gates which changes create a revision;
    ``["*"]`` (the default) reacts to any change. Each snapshot copies the old
    entity verbatim plus ``origin_key``, ``revision_index`` (per-origin counter),
    ``revision_date`` and ``revision_change_list``.
    """

    def prewrite(self, skel: SkeletonInstance, is_add: bool, change_list: t.Iterable[str] = ()):
        if is_add:
            return

        revision_fields: list[str] = getattr(skel.skeletonCls, "revision_fields", ["*"])
        change_list = list(change_list)

        if "*" in revision_fields:
            should_create = bool(change_list)
        else:
            should_create = bool(set(change_list) & set(revision_fields))

        if not should_create:
            return

        origin_key = skel.dbEntity.key

        # prewrite runs before db.Put(), so this is still the old state.
        old_entity = db.Get(origin_key)
        if old_entity is None:
            logger.warning(
                "RevisionDatabaseAdapter: no existing entity found for %s – skipping revision",
                origin_key,
            )
            return

        revision_kind = f"{skel.kindName}_revision"

        existing = (
            db.Query(revision_kind)
            .filter("origin_key =", origin_key)
            .order(("revision_index", db.SortOrder.Descending))
            .getEntry()
        )

        # Within the window the latest revision already holds the "before" state;
        # just merge the change_list into it (collapses auto-save spam).
        coalesce_window = getattr(
            skel.skeletonCls, "revision_coalesce_window", _DEFAULT_COALESCE_WINDOW
        )
        if coalesce_window and existing and existing.get("revision_date"):
            age = utils.utcNow() - existing["revision_date"]
            if age < coalesce_window:
                merged = list(dict.fromkeys(
                    list(existing.get("revision_change_list") or []) + change_list
                ))
                existing["revision_change_list"] = merged
                db.Put(existing)
                logger.debug(
                    "RevisionDatabaseAdapter: coalesced change into revision %s for %s (age %s)",
                    existing.get("revision_index"),
                    origin_key,
                    age,
                )
                return

        revision_index = (existing["revision_index"] + 1) if existing else 1

        new_key = db.AllocateIDs(db.Key(revision_kind))
        revision_entity = db.Entity(new_key)
        revision_entity.exclude_from_indexes = set(old_entity.exclude_from_indexes)

        for k, v in old_entity.items():
            revision_entity[k] = v

        revision_entity["origin_key"] = origin_key
        revision_entity["revision_index"] = revision_index
        revision_entity["revision_date"] = utils.utcNow()
        revision_entity["revision_change_list"] = change_list

        db.Put(revision_entity)
        logger.debug(
            "RevisionDatabaseAdapter: revision %d created for %s (old state preserved)",
            revision_index,
            origin_key,
        )