The versioning model

Copy Markdown

AshVersioned turns an Ash resource into a versioned resource using a Slowly Changing Dimension (type 2) approach. This document covers the mechanism used and its core consequences. Opt-in behaviour is covered in Options.

How it works

Every mutation on a versioned resource appends a new row instead of updating in place. The current row is set to stale (latest_version: false) and a new row (copied from the current row with changes applied) is inserted. The old row is never deleted or overwritten[^1], it just stops being the latest_version. These changes are always executed in a transaction so there are no partial updates.

Resource version integrity is ensured with two unique identities. The first unique identity ensures that there is only one active version of a resource with a partial index on the resource identity where latest_version == true.

identity :current_resource_id, [:resource_id], where: expr(latest_version == true)

The second unique identity ensures that a resource only has one instance of any given version.

identity :full_history_resource_id, [:resource_id, :version_number]

The version integrity and update mechanisms impose structural and behavioural constraints, described in more detail in the rest of this document.

Resource identity

A versioned resource has two distinct identifiers.

  • The identity attribute (the resource_id): the stable identity of the logical object, shared by every version. It never changes for a given object.

  • The primary key (id): the physical identifier of one version (a row). It changes on every mutation. A fetched row's id may be stale by time you read the resource's current id.

Never look an object up by its primary key. id is a data-layer implementation detail, not an application-level handle. Filter, join, and store foreign keys against the identity attribute instead. If you need a specific version, query by identity attribute and version number together.

AshVersioned does not generate a primary key, but requires that you declare a generated surrogate primary key yourself, the same way you would on any other resource. Because the primary key is required only for internal bookkeeping, we recommend public?: false:

attributes do
  integer_primary_key :id, public?: false
  # or uuid_v7_primary_key, uuid_primary_key, ...
end

AshVersioned.Resource verifies at compile time that the resource has a generated surrogate primary key. Missing, compound, or writable (natural) primary keys are compile errors.

History is the purpose

AshVersioned provides a full record of changes for a resource, but most interactions with that resource operate only on the latest version. AshVersioned modifies read actions to include latest_version == true with a preparation.

The version_history read action is exempted from this scope narrowing as its purpose is to see everything, including stale rows.

MyApp.Widget
|> Ash.Query.for_read(:version_history)
|> Ash.Query.filter(resource_id == ^widget.resource_id)
|> Ash.Query.sort(version_number: :asc)
|> Ash.read!()

Read actions that need similar capability should be excluded from this scope narrowing with the exclude_read_actions option.

versioning do
  exclude_read_actions [:audit_trail]
end

actions do
  read :audit_trail do
    primary? false
  end
end

Records cannot be deleted

AshVersioned does not allow destroy actions to be defined as versioned resources should never be deleted. The archive option on the versioning section offers the alternative.

Global preparations require care

If your resource defines additional global preparations, it is imperative that the versioning history action is exempted from those preparations, and strongly recommended that any other read actions in exclude_read_actions be exempted as well. If this is not done, AshVersioned resource updates will probably fail with StaleRecord errors.

AshVersioned.Resource.Info includes four functions that assist with this:

  • versioning_all_excluded_read_actions!/1
  • versioning_history_action!/1
  • versioning_exclude_read_actions!/1
  • versioning_archive_excluded_read_actions!/1

The first function combines both functions, and you pass the query.resource as the argument to any of them. For examples of this, see AshVersioned.Preparations.FilterLatest.

Multitenancy

AshVersioned has been verified to work with Ash's built-in attribute multitenancy.

[^1]: Except for setting latest_version to false and setting the updated

timestamp to when the change was applied.