Lix represents all data through four fundamental concepts that build upon each other.
Changes are the atomic units, which are grouped in change sets, which form a graph, and the graph defines state. This simple hierarchy enables powerful features like versioning (branching), merging, and time-travel queries.
Change A single modification (e.g., c1
).
Change Set A collection of related changes (e.g., {c1, c2}
).
Change Set Graph Multiple change sets linked together showing evolution.
Version A pointer to a specific change set in the graph.
Multiple versions can point to different change sets, creating divergent versions which can be merged later:
Here, Version A and Version B diverge after CS2, each with their own subsequent changes. This enables:
History in Lix is simply pointing to a specific change set in the graph and materializing the state up to that change set. Any change set in the graph can be queried, not just those pointed to by versions.
In this example, the "Historical Query" points to change set {c3, c4}
, allowing access to the exact state at that point in time, even though no version currently points there.
Rather than storing state for every version and historical point, which would require large amounts of storage, Lix stores only the changes. State is then materialized on-demand in a cache by traversing the change set graph and applying the relevant changes.
This approach provides significant storage efficiency: instead of storing multiple complete copies of data, Lix stores each change only once. When state is needed (whether current or historical), it's computed by traversing the graph backward from a change set and applying all leaf changes in the lineage.
State materialization can be simplified to the process of taking the union of all change sets in the lineage and filtering for leaf changes. Leaf changes are the latest change for each entity, ensuring that only the most recent modifications are applied.
Consider this example with two entities (e1
, e2
). The lineage of change sets might look like this:
The union of all change sets in the lineage is taken:
CS1 ∪ CS2 ∪ CS3 = { e1: "benn", e1: "julia", e2: "gunther" }
Filter for leaf changes, which are the latest changes for each entity:
e1
, the latest change is "julia"
from CS2
.e2
, the latest change is "gunther"
from CS3
.The resulting state is:
State = { e1: "julia", e2: "gunther" }
The change set graph in Lix is global and shared across all versions. By having a global graph, all versions share the same understanding of history—each version may have a different lineage, but they all agree on what that lineage contains. If the change set graph were version-scoped, versions couldn't agree on what the history of another version is.
In the global change set graph above:
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 design choice avoids global cascading effects and acknowledges that changes are versionless - they exist outside the version system as the immutable source of truth.
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 example_entity, comment or change‑set element lives inside a specific version, but can freely point at any
lix_change.id
without special handling.