This document explains Lix's technical design, state materialisation algorithm, and data model constraints. For a practical operational guide, see How Lix Works.
Lix represents all data through four fundamental concepts that build upon each other:
In this example, two changes (c1, c2) serve as atomic units—enabling fine-grained diffing and cherry-picking—while the change set cs42 groups them together, signaling they belong together while preserving their individual atomicity. The commit a1 materializes this change set into state, forming a point in time that can be referenced, traversed, and compared. The version main acts as a named pointer to this commit, defining what state is currently visible.
State is expressed by the commit graph. Each commit packages a change set and links to parent commits, forming a directed acyclic graph (DAG) that records how state evolved over time.
Key properties:
Multiple versions can point at different commits, creating divergent histories that remain isolated until merged.
Here, Versions A and B share commit m1 and then diverge. Each version maintains its own pointer until changes are reviewed and merged.
Any commit in the DAG can be materialized, even if no version currently points at it. This enables querying historical state.
Inspecting history means selecting a commit and rehydrating the state that existed there. The file_history and state_history views use lixcol_root_commit_id to walk backwards from any commit.
Lix does not persist full snapshots. Instead it stores:
When you request state, the engine walks the commit graph, gathers the change sets that are reachable from the target commit, and applies the newest change for every entity. The traversal is cached internally so queries stay fast without materializing full snapshots.
Conceptually, materialisation follows three steps:
Consider this example with two entities (e1, e2). The lineage of change sets might look like this:
Step 1: The union of all change sets in the lineage is taken:
CS1 ∪ CS2 ∪ CS3 = { e1: "benn", e1: "julia", e2: "gunther" }Step 2: Filter for leaf changes, which are the latest changes for each entity:
e1, the latest change is "julia" from CS2e2, the latest change is "gunther" from CS3Step 3: The resulting state is:
State = { e1: "julia", e2: "gunther" }Cache behavior:
Trade-offs:
Lix supports foreign key constraints to maintain referential integrity between entities.
For simplicity, Lix only allows foreign keys on entities in the same version scope, with the exception of references to changes themselves. This avoids cascading effects across versions and acknowledges that changes are versionless—they live outside the version system as the immutable source of truth tracked by commits.
| Rule | Rationale | Engine behaviour |
|---|---|---|
| 1. Version‑scoped → change | Changes live outside any version, so the reference is valid across all versions. | Validator skips the version_id = ? check when the target schema is lix_change. |
| 2. Version‑scoped → version‑scoped (same version) | Keeps each version self‑contained and makes deletes cheap. | Current logic stands: both rows must share the same version_id. |
| 3. Change → version‑scoped | Would immediately violate Rule 2. | Disallowed at schema‑registration time. |
Result: An entity or comment lives inside a specific version, but can freely point at any
lix_change.idwithout special handling. System metadata like commits and change-set elements stay in the global scope and follow the same rules when they referencelix_change.
Versions are isolated by design:
Plugins produce changes that feed into this architecture:
detectChanges returns DetectedChange[]change records with entity snapshotsPlugins don't need to understand commits, versions, or the graph—they just report entity-level changes.
The engine exposes this architecture through SQL views:
file, state - Current state at active version's commitfile_history, state_history - Historical states via graph traversalchange - Individual change recordscommit - Commit graph nodesversion - Named pointers into the graphApplications query these views without needing to understand state materialisation or graph traversal—the engine handles that complexity.