What Started as a Bugfix: Revision Graph 4.0.0

Shibin Das avatar
Shibin Das

Revision Graph draws a node’s revision history the way a Git client draws history: one row per revision, a lane per language, and the branching shown rather than described.

4.0.0 is out, and it started as a bugfix.

A node with English, Afrikaans and Bengali translations was drawing wrong. The screen was two panels: a graph on the left, a list of revisions on the right. A revision’s facts — is this the current one, the latest, the published one, the live one, the default one — were text chips in the list, and the graph tried to echo them by ringing the dot.

It could only echo two of them. Live drew a green ring and Default drew a dark disc; the other three had no styling at all and drew invisible circles. And both of the marks that were drawn landed on one language’s dot — whichever language the revision happened to touch. On this node that was Bengali, so the graph read as though Bengali owned the default revision, while the English revision readers were actually served sat there unmarked.

How 3.x showed a revision's facts, and how 4.0.0 shows them In 3.x the screen was two panels. The graph on the left could only draw two of a revision's facts, as a dark disc for the default revision and a green ring for the live one, both at the same radius on the same dot. The list on the right carried all five facts as text chips: Current, Latest revision, Published, Live and Default. In 4.0.0 there is one panel: the node-wide facts are stated in words above the rail and repeated as a single label on the row they are about. 3.x — two panels, and two of five facts drawable Commit Graph Commit History en es de 2 marks, one radius Publish english! #78 · Taste Buds · 22h ago Current Latest revision Published Live Default Five facts, five chips — but only Live and Default had any styling, so the other three drew invisible circles. Both drawn marks sit on one language’s dot — whichever the revision happened to touch. 4.0.0 — one rail, and the facts in words Default revision #78 Live now: en #78 · es #76 · de #74 #78 Publish english! DEFAULT The dot now says only what is true of this revision in this language. The node-wide facts are text.
The old dot could carry only two of the five facts, and both landed on one language. Every tag appended a circle at the same radius, so they overlapped rather than stacked — and only Live (a green ring) and Default (a dark disc) had any styling, leaving the other three as invisible circles you could read only from the list. That split, a graph that can't say it beside a list that can, is what 4.0.0 collapses into one rail.

So: stop drawing that ring on a language’s dot. One afternoon, surely.

Except to know which dot a fact belongs to, you have to know what a dot is — and that question turns out to have no clean answer in Drupal.

“Parent” means three different things

Here is the heart of it, and it is worth knowing even if you never install this module. Ask what a revision’s parent is, on a translated node with drafts, and there are three correct answers.

Three different right answers to “what is revision 5's parent?” A node with five revisions, listed newest first as the rail displays them: a German translation added at 5, an English draft at 4, English published at 3, an English draft at 2, and English published at 1. Revision 5 has three defensible parents. The revision it was built from is 3, not 4, because a draft never becomes the default revision. The language it was copied from is English, which no revision ID can express. And a visitor only ever saw revisions 1, 3 and 5, because 2 and 4 were never published. One node’s history newest first, as the rail reads #5  de translation added live now #4  en draft never public #3  en published was public #2  en draft never public #1  en published was public So what is #5’s parent? Three answers, all correct, all about different things. ① the revision it was built from #3 — not #4 A draft never moves the pointer, so the form loaded the last published revision. ② the language it was copied from English German didn’t come from “revision 3”. It came from English, as it stood at 3. ③ what a visitor actually saw #1 → #3 → #5 Readers are served published revisions only. For them, #2 and #4 never happened.
This is why two revision graphs can disagree and both be right. ① is a fact about a save, ② is a fact about a language, and ③ is a fact about what was published. Revision Graph draws the first two, and says so — because a reader who assumes it draws the third will misread every draft on the page.

Answer ① is the surprising one in practice. When you open a translation form, Drupal builds it from the revision it loads normally — the default revision, the last published one. So a translation’s parent is usually not the newest revision; any draft sitting above it is not in its lineage at all.

Answer ② is why storing a revision number was never going to be enough. German didn’t come from revision 3, it came from English. A parent here is a (revision, language) pair, and half of it is a language.

Answer ③ is the one nobody stores, and the one most people picture. A lane in the graph is where content came from — not the sequence of versions the public saw.

Drupal doesn’t record any of this

There is no parent_vid column. node_revision gives you an ID, a timestamp, an author and a log message, and that is the end of the topology. Revisions are a list, not a tree.

So a module drawing the tree either keeps the answer itself or works it out afterwards. This one has now done both, in that order.

From 2019 we kept our own table, with a parent and a branch name per revision. Getting the parent into it was the awkward part, and the commit messages from that week are honest about it: “introduce a hackish way to store revision parent”, then “Bugfix parent version hunt”. The code read the wrong property and compensated by parsing the current URL for /node/X/revisions/Y/revert — a storage layer reading a route to work out what it was storing, and blind to any revert not done by hand in the UI. In 2024, issue #3417493 with tstoeckler replaced thirteen lines of that with the call that was always the right answer:

$parent_version_id = $entity->getLoadedRevisionId();

Then the 3.0.0 refactor dropped the table. The commit is called “Code cleanup”, and that is the whole recorded rationale. For the 3.x line the edges were inferred instead — and inference is fine until it isn’t: a revert looks exactly like an edit that happened to restore old values. Those are precisely the cases a history graph exists for.

4.0.0 records it again, as a base field written at save time. A field rather than a side table because core then manages its life: it travels with the revision and goes when the revision goes. The old table had no delete handling at all, so deleting a revision left its row behind, pointing at something that no longer existed. An edge the module recorded is then drawn a pixel heavier than one it merely inferred — a small thing, but it means the graph never claims to know more than it does.

Existing revisions are not backfilled. They keep their inferred edges. Reconstructing them would mean guessing, and a graph that draws a confidently wrong edge is worse than one that draws fewer.

What the fix actually was

Not a rendering tweak. The dot was carrying facts at three different scopes and had vocabulary for none of them, so each scope got its own channel.

A dot answers two independent questions: its ring and its centre The ring says whether the revision was ever the default revision: solid for yes, dashed for never. The filled centre says whether it is live right now, meaning it is both this language's current revision and published. Because a language has exactly one current revision, at most one dot per language carries a filled centre, and none does when that language's current revision is unpublished. Facts a shape cannot carry are printed on the row as text labels reading DEFAULT, PENDING or UNPUBLISHED. A dot answers two questions at once the ring: was this ever the default revision? the centre: is it live now — current and published? was the default revision once not served now never the default revision a pending draft live right now what readers get at most one per language the row says the rest in words: DEFAULT PENDING UNPUBLISHED
Two questions, two marks, and words for everything else. The ring is about the past — was this revision ever the one the site served? The centre is about right now, and since a language has exactly one current revision, only one dot per language can carry it — none at all when that language’s current revision is unpublished, which is what the UNPUBLISHED label is for.

The two channels are worth stating exactly, because they are easy to conflate. The ring is trunk — was this revision ever the default revision, the one the site served? The centre is state === 'live' — is this revision both its language’s current revision and published?

The second has a consequence worth knowing before you read a graph: since a language has exactly one current revision, at most one dot per language can be filled — and none is, when that language’s current revision happens to be unpublished. If you are hunting for what a visitor gets right now, you are looking for one filled dot per lane, and its absence is information too.

Facts about the whole node are now said in words above the rail — Default revision #9 · Live now: af #8 · bn #9 · en #5 — and repeated as a text label on the row they are about, in a status column, rather than as a mark on one language’s dot.

That last line is the annoying part. It would have prevented the original misreading on its own, it is the thing an editor opens the page to find out, and it needed none of the rest of this work. But it is only trustworthy because of the rest: a header stating what’s live, generated by a graph that couldn’t say what a dot was, is a sentence I’d have had no reason to believe.

References

Back the work behind the writing

These posts come out of building things in the open — Atelier, FlowDrop, and a long tail of Drupal contributions. Free to use, self-hosted, and yours to run. If any of it saved you time, you can help keep it going.

Sponsor monthly Buy me a coffee
What this funds
Shibin Das

Created by Shibin Das

Drupal Developer. Spice Dealer. Prying on Information Architecture nowadays.

Recommended for You

Musing

The Bet Ships: Atelier Is Live

Musing

Am I Building on Sand? Betting a New Product on an Unfashionable Foundation