Skip to content

Content versions

Load, save and query the version of content a site serves, and reach the canonical version where your code means it.

Needs Sites content overrides

Part of Sites content overrides.

A site can keep its own version of content whose canonical site is another site: an override (Content overrides). It is a revision of the entity carrying the site's id in site_id and the fields it pins in site_override_fields. The canonical version is the default revision, without a site_id. Each override revision is a full snapshot of the entity, so storage grows with every site that overrides it.

Which version code gets

sites.content_version_context (Drupal\sites\ContentVersionContextInterface) decides it for every load, save and query: versionSite() returns the site whose version applies, or NULL for the canonical one.

Where the code runs Version
A site's host, content the site overrides the site's override
A site's host, any other content canonical
The hub canonical
JSON:API and REST requests canonical (CANONICAL_REQUEST request attribute)
Batch API operations, cron canonical
Inside executeCanonical() canonical, until a site switch inside it

Code that means the content as such, on any host, runs in the canonical scope:

$versions = \Drupal::service('sites.content_version_context');
$node = $versions->executeCanonical(fn () => $node_storage->load($nid));

A site switch inside the callback brings that site's version back for as long as the switch lasts.

Load

Where versionSite() names a site, load() and loadMultiple() deliver the revision the site serves: its newest override revision, unless hook_sites_content_overrides_effective_override_alter() picks another or none. The content moderation submodule uses that hook to keep serving the published override while a draft is in progress.

Override revisions are the newest revisions by number, yet what core calls the latest revision (getLatestRevisionId(), isLatestRevision(), an entity query with latestRevision()) skips them: it is the default revision or a pending draft of it. Code that edits the latest revision, like JSON:API's working copy or tool_belt:entity_update, edits the canonical entity, and the sites keep what they pin. To read what a site serves, load through entity.repository->getActive(), which answers for the site in context.

The resolver (sites_content_overrides.site_aware_entity_resolver, SiteAwareEntityResolverInterface) answers the other questions:

Method Returns
loadDefaultRevision($entity_type_id, $id) the canonical version, wherever the code runs
loadSiteOverrideRevision($entity, $site) the newest override revision, the one editing continues on
loadEffective($entity, $site) what $site serves
servedRevisionIds($entity_type_id, $ids, $site) the served override revision ids, without loading

Save

On the host of a member site that is not the content's canonical site, $entity->save() writes that site's override and creates it when the site has none. Only the fields the site may override keep the values the code set; every other field takes the canonical value. On the hub, on the canonical site's host and for content the site does not hold, a save is canonical. To change the canonical version from a site's host, save inside executeCanonical().

Query

Entity queries and views on a site's host filter, sort and count on the version the site serves, the version a load delivers: a site that unpublishes its override does not list the item. A query for revisions (allRevisions()) keeps the canonical tables.

The swap runs as the last alter of entity_query and views queries, after node access and the latest-revision alters. It replaces only what the site's overrides pin, each with a derived table of the same columns:

  • the columns of pinned fields in the data table (a pinned moderation_state also swaps the published flag);
  • the dedicated tables of pinned fields.

Every other table and column stays canonical, indexes included. Only revisionable SQL entity types with integer ids are swapped.

The served-revision table

The swap joins sites_content_overrides_served, which holds the revision each site serves per entity:

Column Content
entity_type_id the entity type, like node
site_id the site that serves an override
entity_id the entity id
revision_id the revision the site serves

The primary key is (entity_type_id, site_id, entity_id). There is no row for the canonical site, for a site without an override, or for an override the site does not serve (a draft). An insert, update or revision delete of an overridable entity rewrites its rows once the root transaction commits, at the latest before the next read or at the end of the request.

Your own SQL

A query that is not an entity query or a view reads the canonical tables. To read what site globex serves, join the table the way the swap does:

$query = $database->select('node_field_data', 'd');
$query->leftJoin('sites_content_overrides_served', 's',
  "[s].[entity_type_id] = 'node' AND [s].[site_id] = :site AND [s].[entity_id] = [d].[nid]",
  [':site' => 'globex']);
$query->leftJoin('node_field_revision', 'r',
  '[r].[vid] = [s].[revision_id] AND [r].[langcode] = [d].[langcode]');
$query->addField('d', 'nid');
$query->addExpression('COALESCE([r].[title], [d].[title])', 'title');

Overridable entity types

sites_content_overrides.settings:override_entity_types lists the entity types sites may override. Taking a type off the list uninstalls its site_id and site_override_fields fields and deletes every override of the type. A configuration import that would do so while overrides of the type exist fails validation.