Designing a database for spatial interfaces
In loving memory of Christophe Vandeputte
The simplest spatial database is a list of objects with coordinates. Each object carries an x, a y, a width, a height. Render them onto a canvas and you have a spatial interface. It works until you need the same object in two places.
This is the problem transclusion solves. Ted Nelson coined the term in 1981, in Literary Machines, but the idea traces back to his hypertext work in the 1960s. Transclusion is a data model that reuses information instead of duplicating it. Every entity has a minimum of three fields: its data, a list of children, and a list of hosts. The relation is bidirectional. Any object can look down (what's inside me?) and look up (where do I appear?).
An object can have multiple hosts. The same photo can live in your research board and your presentation and a shared workspace someone else owns. Same data. Three contexts. One source of truth.
But we need a way to discriminate. If position lives directly on the object, that photo sits at the same coordinates in every context. Drag it in one and it jumps in all of them. We obviously don't want that.
To solve this in Kosmik, we introduced edges. An edge is an in-between object that sits between a guest (the object being placed) and a host (the context it appears in). Position doesn't belong to the object. Position doesn't belong to the universe. It belongs to the relationship between them.
An object has more fields than data, children, and hosts. We added them as we needed them. The one that matters here is "polys," short for polymorphic data: typed metadata keyed by flavor. Position is a poly with flavor "spatial." Size is a separate poly. So is crop geometry. The edge carries all of these, not the object, because they're all contextual.
What an edge carries
In pseudocode:
type CosmicObject = {
id: string
data: Uint8Array
children: Record<Type, EdgeId[]>
hosts: Record<Type, EdgeId[]>
}
type Edge = {
id: string
guest: ObjectId
host: ObjectId
polys: {
spatial?: { origin: [number, number] }
size?: { width: number; height: number }
crop?: { x: number; y: number; w: number; h: number }
}
}
The edge is a first-class object with its own ID and version history. Not a join table. Not a foreign key pair. A full entity that carries data neither the guest nor the host should own.
polys.spatial.origin is the position. The object knows nothing about where it sits on any
canvas. The edge knows exactly where the object appears in one specific context.
An object transcluded into three universes has three edges, each with its own origin. Move it in Universe A and only that edge's spatial poly changes. Universes B and C are untouched. The object's data never changes at all.
The API:
object.transcludeInto(universe) // creates an edge
universe.transcludeOut(object) // deletes the edge
edge.transcludeMove(newUniverse) // atomic: out + update + in
You can create circular transclusions. An object can host a universe that hosts a copy of that same object. This sounds like a bug, but it's the point: any data can be reused in any context. If you want to prevent cycles, enforce that in the UX layer, not the database.
Querying across contexts
Two directions of query, both going through edges.
Object-centric: "Where does this photo appear?" Read object.hosts, get back a map of edges
grouped by host type. Each edge points to a host and carries polys. You now know every
context this object lives in and where it sits in each one.
const hosts = await photo.getHosts(branch)
// { universe: [edge1, edge2], frame: [edge3] }
// edge1.polys.spatial → { origin: [200, 150] }
// edge2.polys.spatial → { origin: [40, 80] }
Context-centric: "What's in this universe?" Read universe.children, get back edges grouped
by guest type. Walk each edge to resolve the guest object, then read the edge's polys for
position.
const children = await universe.getGuests(branch)
// { image: [edge4, edge5], text: [edge6] }
// edge4.guest → photo object (the data)
// edge4.polys.spatial → { origin: [200, 150] }
The join always goes through the edge. Object data is never duplicated. Position is always contextual. In practice, rendering a universe means walking its edges and loading every guest. Every render starts with a graph traversal.
What this costs
Edge count scales multiplicatively. An object in ten universes means ten edges, each versioned and synced independently. In Kosmik, edges are full cosmic objects with their own Merkle-based version history. The storage overhead per transclusion is real.
Deletion is actually where the model shines. You don't cascade-delete edges when removing an
object. You transcludeMove it into a trash context. One edge update, one operation. The
object and all its data survive. Undo is just a move back. But the bidirectional invariant is
still tricky to maintain. An edge's guest field points to an object. That object's hosts list
should contain the edge. If those get out of sync (a partial write, a sync conflict, a
missed update) you have a dangling reference that's hard to detect without walking the graph.
We've hit this.
None of this is a reason to avoid transclusion. It's the cost of a model where data lives in one place and appears in many. The alternative is copying: duplicate the photo into each universe with its own position. No edges, no graph traversal, no cascade complexity. But copies diverge the moment someone edits one, and then you're building sync anyway, except now you're syncing data instead of relationships. We tried that first. Edges are simpler.
Where this is heading
There's no standard library for transclusion. No ORM understands edges. Every team building spatial tools ends up designing some version of this, usually after the flat list of objects with coordinates stops working.
We've tried the alternatives and we keep landing on edges. The real question is how far down the stack they should go. Right now, transclusion is an application-layer graph we build on top of whatever storage we have. The database doesn't know that an object can live in three places at once, that position is contextual, that deleting a universe means walking a graph.
I think the database should know.