Field guide · TreeDB

The topics a treedb keeps for itself.

Three topics you never declare, and must never write. Each one has a door, and the door is the whole interface.

Open any treedb and it will already contain topics you did not put in the schema. Their names start with a double underscore, they are marked system_topic, and delete-topic refuses them by name. They are the store's own machinery: the ledger of its snapshots, the drawing of its graph, the index of its bytes.

A system topic is not a topic you write. The system owns it, keeps it consistent with things that live outside it — a flag inside every record's metadata, a file on disk, a schema in another treedb — and opens a door onto it: a command, a Save button, the write path of a column. Reaching past the door with create-node, update-node or delete-node on the topic itself is not using the treedb. It is hacking it, and what breaks is not the row you edited.

YOU THE DOOR THE TOPIC AND ALSO operator, GUI, another yuno shoot-snap / activate-snap the graph's Save create/update-node on YOUR topic, with a file column __snaps__ __graphs__ __assets__ a tag inside every record nothing — it is the drawing bytes in .blobs/ update-node on a system topic

Every system topic is the visible half of something. Write the visible half by hand and the other half does not follow: the tag stays on the records, the bytes stay on disk, and the store is now telling two different stories.

__snaps__ — the ledger of snapshots

A snapshot in a treedb is not a copy of anything. It is a number stamped into the records themselves — and __snaps__ is the ledger that says which number means what.

__snaps__ one row per snapshot — the row's id IS the tag topic_version 3
ColumnFlagsWhat it holds
id rowid Assigned by the store, and it is the tag. It rides the user_flag of every record the snapshot froze — one uint16_t per record instance, so ids stop at 0xFFFE and 0 means “no snapshot”.
namerequired What you called it. Shooting a second snapshot under a name already in the ledger is refused.
daterequired UTC, as a string05 Sep 2026 17:04:11 +0000. It is a label to read, not a field to sort on.
activerequired The one bit that matters at run time: which snapshot the treedb loads at its next open. At most one row carries it.
description Why you shot it. The only part a person writes, and they write it as an argument to shoot-snap.

The doors

shoot-snap name=<n> Adds the row, then stamps its id on the current record of every topic. Idempotent by name: an existing name is refused, not overwritten.
activate-snap name=<n> Moves the active bit. It changes what the treedb will load, so it takes effect on the next open — which is why the agent, whose own snapshots work the same way, ends its version by restarting what is running.
deactivate-snap Clears the bit (activate-snap name=__clear__ in the API): back to the live tree.
snaps Reads the ledger. The only door that is safe to hold open.
snap-content [topic_name=<t>] What a given snapshot actually holds, by topic. Takes the snap by name or by snap_id.
delete-node on a snap row The one hand-written call the design intends: there is no delete-snap. Dropping the row is how a snapshot is discarded, and it is what frees the assets only that snapshot was holding.
keep out Do not write active yourself, and never edit an id. The bit and the tag are two halves of one fact: the ledger says “tag 7 is the snapshot called pre-upgrade and thousands of records carry the 7. Renumber the ledger and every one of those records now names a snapshot that does not exist.

The mechanism is worth following once, because it explains both the uint16_t and the reload.

shoot The row is written first, to get an id. The id is the tag, so the ledger entry has to exist before anything can be stamped with it.
stamp Every current record of every topic takes the tag. In place when the record carries no tag yet — no extra storage, nothing copied. Topics whose name starts with __ are skipped: the machinery is not part of the picture it takes.
clone A record already tagged by an older snapshot is appended again. One instance carries exactly one user_flag, so a second snapshot cannot share it: a clone is appended with the new tag and the original keeps the old one. This is the only case where a snapshot costs storage — and it is why the older snapshot still loads.
activate The active bit moves; nothing else happens yet. The tag is a filter applied when the treedb loads its topics (user_flag == tag, newest instance per key). A running treedb has already loaded.
reload On the next open the store is the snapshot. Writes from there inherit the tag, so the snapshot moves forward with you until you clear it.

__graphs__ — where the drawing is kept

Every treedb is a graph: topics are nodes and hook/fkey columns are edges. That much is derived from the schema and needs no storage. What cannot be derived is where a person decided each card should sit — and that is all __graphs__ holds.

__graphs__ one row per topic drawn — the layout, not the graph topic_version 12
ColumnFlagsWhat it holds
idrowid In practice the name of the topic being drawn: the writer sends it, so a second Save updates the same row instead of adding one.
topic requiredwritable Which topic this layout belongs to. What the reader indexes by.
activewritable Whether the layout is applied. A row that is not active is kept and ignored — which is how a saved arrangement survives a spell of automatic layout.
time timewritable When it was saved.
properties blob writable The drawing itself: nodes keyed by node id with their x, y and size, edges with their styles, and __origin__ — the node_uuid of the machine whose user arranged it.

The doors

the graph's Save In edition mode, in any treedb graph. One write per topic, not one per node, and it creates the row if there is none.
the layout selector manual reads these rows back; dagre ignores them and re-arranges on every load. Choosing manual is what makes a Save mean anything.
the exception This is the one system topic whose door is the raw call: Save sends an ordinary update-node on __graphs__, with create set. It can afford to, because it is the one with nothing on the other side to keep in step — no tag in the records, no bytes on disk. Writing it by hand still gains you nothing, and can lose you something: __graphs__ is deliberately excluded from the topics a graph draws, so a row for a topic that is not there is a card of nothing.

It is the only system topic with no other half. Delete every row and you lose an arrangement, not a fact — the graph comes back laid out automatically, and somebody drags it into shape again.

/*  what one Save writes -- one row, all of a topic's cards  */
{
    "id": "devices",
    "topic": "devices",
    "active": true,
    "properties": {
        "nodes": { "dev-0417": {"x": 240, "y": 96, "size": [180, 60]}, … },
        "edges": { … },
        "__origin__": "a4f1…"   // the node_uuid that arranged it
    }
}

__assets__ — the index of the bytes

The third one, added in 7.18.0, is the index of the files a file column points at: one row per content, keyed by the sha256 of the bytes, with the bytes themselves in .blobs/ beside the treedb. Its hooks are not declared either — they are derived from the file columns of your own topics.

It has the strictest door of the three, because it is the one with real bytes on the other side: you never write it at all. You write your topic, handing the file over in the same message, and the write path does the rest. There is a whole page on that one: the life of a file in a treedb.

__assets__ one row per content — the bytes are in .blobs/ topic_version 1

The doors

create-node / update-node On your topic, with the file beside the record. The write path stores the bytes, writes the row and links the column itself.
import-assets A whole directory the node already has. Not one byte crosses the network.
get-asset Reads one back: a signed URL where the service can sign, the bytes inline where it cannot.
gc-assets Takes what no live node and no snapshot holds — and the bytes no row names. Run it with dry_run=1 first.
keep out An __assets__ row and its blob are one object in two places, and only the write path keeps them together. A row written by hand names bytes that are not there; a row deleted by hand can take bytes another treedb of the same tranger is still using — which is exactly why delete-node on an asset runs the collector's own guards, and why force overrides none of them.

Three rules that hold for all of them

  • You never declare them and you never version them. They are created by treedb_open_db() on every open, with their own topic_version that belongs to the framework. Your schema_version has nothing to say about them.
  • delete-topic refuses them. They carry system_topic: true, persisted with the topic and tested before anything is removed. That is a guard, not a suggestion.
  • The __ prefix is load-bearing. It is what shoot-snap tests to skip a topic, so the machinery is never part of the picture it takes. Do not name a topic of your own that way — and do not confuse these with __system__, which is not a topic at all but a separate treedb, the one that holds every treedb's schema as data.

Source: kernel/c/timeranger2/src/tr_treedb.ctreedb_open_db() creates both topics inline, treedb_shoot_snap() and treedb_activate_snap() are the snapshot mechanism, and treedb_delete_topic() holds the system_topic guard. The only writer of __graphs__ is kernel/js/gobj-ui/src/c_g6_nodes_tree.js. Companion page: the life of a file in a treedb.