Designing a database for spatial interfaces

2026 · 05 · 1,300 words

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, and a height; render them onto a canvas and you have yourself a spatial interface. It works right up until the moment you need the same object in two places at once.

This is the problem that transclusion solves. Ted Nelson coined the term in 1981, in Literary Machines, though the idea traces all the way 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, so any object can look down (what's inside me?) and look up (where do I appear?).

An object can have many hosts. The same photo can live in your research board, in your presentation, and in a shared workspace that someone else owns entirely: the same data, three contexts, one source of truth.

But now we need some way to tell those contexts apart. If position lives directly on the object, then that photo sits at the same coordinates everywhere it appears. Drag it in one place and it jumps in all of them, which is obviously not what anyone wants.

UNIVERSE AphotonotessketchUNIVERSE Bphotonoteslink
Position on the object. Drag a highlighted block: it moves in both universes at once.

So 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, then, doesn't belong to the object, and it doesn't belong to the universe either. It belongs to the relationship between the two.

Beyond position

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 its own version history. It is not a join table, and not a foreign key pair, but a full entity that carries data which neither the guest nor the host should really own.

polys.spatial.origin is the position. The object itself knows nothing about where it sits on any given canvas, while the edge knows exactly where the object appears in one specific context.

An object transcluded into three universes therefore has three edges, each one with its own origin. Move it in Universe A and only that edge's spatial poly changes; Universes B and C are left completely untouched, and the object's data never changes at all.

objectdata: {...}children: []hosts: []universedata: {...}children: []hosts: []UNIVERSEempty
Click to create or remove a transclusion. Drag the object to update the edge's position.

The API:

object.transcludeInto(universe)   // creates an edge
universe.transcludeOut(object)    // deletes the edge
edge.transcludeMove(newUniverse)  // atomic: out + update + in

You can even create circular transclusions: an object can host a universe that in turn hosts a copy of that same object. This sounds like a bug, but it's actually the whole point, because any data should be reusable in any context. If you want to prevent cycles, enforce that up in the UX layer, not down in the database.

Querying across contexts

There are two directions you can query in, and both of them go through edges.

Object-centric is the question "where does this photo appear?" You read object.hosts and get back a map of edges grouped by host type. Each edge points to a host and carries its polys, so now you 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 is the question "what's in this universe?" You read universe.children and get back edges grouped by guest type. Walk each edge to resolve the guest object, then read that edge's polys for the 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, and position is always contextual. In practice, rendering a universe means walking its edges and loading every guest, so every single render starts with a graph traversal.

What this costs

Edge count scales multiplicatively. An object placed in ten universes means ten edges, each one versioned and synced independently, and in Kosmik edges are full cosmic objects with their own Merkle-based version history. The storage overhead per transclusion is real.

Deletion, on the other hand, is where the model actually shines. You don't cascade-delete edges when someone removes an object; you transcludeMove it into a trash context instead. One edge update, one operation. The object and all of its data survive, and undo is simply a move back.

The bidirectional invariant is the genuinely tricky part. An edge's guest field points to an object, and that object's hosts list should contain the edge. Let the two drift out of sync (a partial write, a sync conflict, a missed update) and you have a dangling reference that's hard to detect without walking the graph. We've hit this ourselves.

None of it is a reason to avoid transclusion, though. It's simply the cost of a model where data lives in one place and appears in many. The alternative is copying: you duplicate the photo into each universe with its own position, and you get no edges, no graph traversal, and no cascade complexity. But copies diverge the instant someone edits one of them, 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 right after the flat list of objects with coordinates stops working for them.

We've tried the alternatives, and we keep landing back on edges. The real question now is how far down the stack they should go. Right now transclusion is an application-layer graph that we build on top of whatever storage we happen to have. The database underneath doesn't know that an object can live in three places at once, that position is contextual, or that deleting a universe means walking a graph.

I think the database should know.