Versioning & history

Versioning and history

A version is two documents and the difference between them. Core builds its whole history story on that one idea: a sparse layer that turns one Document into another. Snapshots are free, the difference between two snapshots is a Document, merging two of them surfaces conflicts instead of dropping an edit, and an append-only log of those differences replays the model as of any point in its past.

The diff is the unit

The difference between two documents is itself a Document: a sparse layer of the changes. diff(base, target) computes it, overlay(base, layer) applies it, and apply collapses the result back into a standalone base. The round-trip is exact: apply(base, diff(base, target)) equals target.

These three are in the Python binding as methods on the document:

import kinogaki as kg

base   = kg.deserialize(open("scene.prisma").read())
target = base.overlay(...)                 # some edited version

patch = base.diff(target)                  # the sparse layer of changes
again = base.apply(patch)                  # standalone, collapsed
assert again == target

Composition covers reference and overlay in full: a reference links one file into another, and an overlay layers a sparse opinion over a base. This page is the version story those primitives carry: snapshots, merge, move detection, and the event log.

Snapshots are free

A Document copies in O(1). A copy shares its structure with the original and duplicates nothing; the first mutation on either side un-shares its own copy. Holding a version costs a pointer until something diverges, so keeping a before-and-after pair around, or a stack of past states, stays cheap. The C++ copy is the copy constructor; Python spells it doc.copy(), and the copy preserves node-id lineage so renames (below) can read a move.

Document base = loadScene();

Document before = base;        // O(1): shares structure, copies nothing
edit(base);                    // base un-shares here; before is untouched

Document patch = diff(before, base);   // what the edit changed

before stays exactly as it was. The snapshot is a value, not a transaction you have to close.

Merge surfaces conflicts

overlay with several layers is last-writer-wins: stack two layers that both write the same slot and the later one silently wins. For two concurrent edits off a shared base that loses an edit with no signal. conflicts and merge close that gap.

conflicts(a, b) returns the overlapping, disagreeing writes between two layers, each naming the path and a detail ("property 'color'", "delete vs edit", "connection", "reorder"). An empty result means the layers are compatible. merge(base, a, b) returns a MergeResult: on a clean merge ok is true and merged holds overlay(base, {a, b}); otherwise ok is false, conflicts lists the overlaps, and merged is empty (None in Python). The caller decides what to do, and no edit is lost in silence.

Document a = diff(base, aliceVersion);
Document b = diff(base, bobVersion);

MergeResult r = merge(base, a, b);
if (r.ok) {
    save(r.merged);
} else {
    for (const Conflict& c : r.conflicts)
        report(c.path, c.detail);          // both edits touched c.path, disagreeing
}

conflicts(a, b) and conflicts(b, a) report the same set: the order of the two layers does not change what overlaps.

Renames read as moves

When you rename or reparent an element, a path-only diff sees a delete at the old path plus an add at the new one. The element is the same; only its address changed. renames(base, target) recovers that as the move it was: a list of Move, each carrying the stable NodeId, its from path, and its to path (from_path / to_path in Python, since from is a keyword).

Document edited = base;
edited.rename(Path("/cast/hero"), Path("/cast/protagonist"));

for (const Move& m : renames(base, edited))
    log("moved", m.from, "->", m.to);      // one Move, not a delete + an add

The NodeId is an in-memory identity that survives a rename (see the model). renames is valid only for lineage-related documents: target is a snapshotted-then-edited copy of base, so the two share the same id space. Two independently loaded documents number their ids from scratch, so compare those with diff, not renames.

The event log

EventLog turns a stream of commits into an append-only, replayable history. It holds a base plus an ordered stack of diff layers, each commit carrying a monotonic offset.

EventLog log(initialScene);          // offset 0 is the base

std::uint64_t v1 = log.commit(afterFirstEdit);   // stores diff(head, next)
std::uint64_t v2 = log.commit(afterSecondEdit);

Document now  = log.head();          // current state
Document past = log.at(v1);          // replay the state as of offset v1

commit(next) stores diff(head, next) as the next layer and returns the new head offset. at(offset) replays the document as of any retained offset. since(offset) returns the layers strictly after a given offset, in order: the catch-up feed a replica or subscriber replays to reach head.

for (const Document& delta : log.since(consumerOffset))
    ship(delta);                     // the CDC / replication feed

History grows without bound, so compact(keep) is the garbage collector: it folds the oldest layers into the base and keeps the most recent keep uncollapsed for time-travel, raising earliest_offset while leaving head() unchanged. head_offset and earliest_offset bound the range you can still replay (they are methods in C++, properties in Python).

The log adds no new persistence and no new format. It is diff, overlay, and compact arranged as an offset-addressed timeline.

These five fit together: diffs are the unit, copies make snapshots free, merge keeps concurrent edits, moves stay legible across a rename, and the event log is the timeline they all live on.