Content Relations, Layer 1: the typed edge graph
Every content system eventually has to deal with the fact that items reference each other. A blog post cites a research paper. A product page references a specification. A doc page links to an API reference. These connections exist, but are usually invisible. They live as raw strings inside a body field, or as a hard-coded ID in a template, or in nobody's code at all because the developer knew which item pointed where and trusted that knowledge to stay true.
Content Relations makes those references explicit. Smeldr now maintains a typed edge graph: a live data structure that the framework actively keeps correct. It records which items depend on which others, what kind of dependency each is, and whether each dependency remains structurally valid.
The schema
Two tables back the graph. smeldr_relation_kinds is a registry of named edge categories. smeldr_relations is the edge table proper.
CREATE TABLE smeldr_relation_kinds (
id TEXT NOT NULL PRIMARY KEY,
type_name TEXT NOT NULL UNIQUE,
label TEXT NOT NULL DEFAULT '',
mode TEXT NOT NULL, -- "derived" | "asserted" | "inferable"
directional INTEGER NOT NULL DEFAULT 1,
weighted INTEGER NOT NULL DEFAULT 0,
type_pairs TEXT NOT NULL DEFAULT '[]',
attributes TEXT NOT NULL DEFAULT '{}',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
CREATE TABLE smeldr_relations (
id TEXT NOT NULL PRIMARY KEY,
source_type TEXT NOT NULL,
source_id TEXT NOT NULL,
target_type TEXT NOT NULL,
target_id TEXT NOT NULL,
relation_kind TEXT NOT NULL,
edge_class TEXT NOT NULL, -- "asserted" | "inferred"
confidence REAL,
valid_at DATETIME,
invalid_at DATETIME,
created_by_job TEXT,
attributes TEXT NOT NULL DEFAULT '{}',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
A relation kind defines the category of edge: its mode, whether it is directional, and which source/target type pairs are valid. An edge (RelationEdge) is a single typed adjacency between two content items. The edge_class field distinguishes between edges that an operator or save-path hook asserted ("asserted") and edges that an AI agent proposed for review ("inferred"). A confidence score and temporal bounds (valid_at, invalid_at) are optional; the sweep uses invalid_at to mark stale edges without deleting them.
Wiring
RelationStore wraps the database and an in-memory RelationKindRegistry that is hydrated at startup so kind lookups never hit the database:
store, err := smeldr.NewRelationStore(db)
if err != nil {
log.Fatal(err)
}
app := smeldr.New(cfg).
Relations(store)
App.Relations does three things at once: it stores the RelationStore, it installs a SyncSaveHook that recomputes asserted edges every time a content item is saved (Layer 1), and it subscribes the cascade handler to AfterPublish, AfterArchive, AfterDelete, and AfterUnpublish (Layer 2). The structural sweep (Layer 3) is wired separately via the agent module and covered in the third post in this series.
Registering a kind and asserting an edge
Kinds are registered via UpsertKind, which is idempotent and safe to call on every boot:
err := store.UpsertKind(ctx, smeldr.RelationKindDef{
TypeName: "cites",
Label: "Cites",
Mode: "asserted",
Directional: true,
TypePairs: json.RawMessage(`[
{"source_type": "post", "target_type": "source"}
]`),
})
Once the kind is registered, edges can be asserted programmatically:
err := store.Assert(ctx, smeldr.RelationEdge{
SourceType: "post",
SourceID: postID,
TargetType: "source",
TargetID: sourceID,
RelationKind: "cites",
EdgeClass: "asserted",
})
Or queried by source, target, or both:
edges, err := store.GetBySource(ctx, "post", postID, "cites")
dependents, err := store.GetByTarget(ctx, "source", sourceID, "")
GetByTarget with an empty kind string returns all edges that point at a given item, regardless of relation kind. This is the "who depends on this?" query and the basis for impact preview.
Layer 1: save-path sync
When App.Relations is called, a SyncSaveHook is installed. After every successful save of a runtime-defined content type, the hook calls RecomputeAsserted with the current set of relation fields extracted from the item's data. RecomputeAsserted performs a differential update: it reads the existing asserted rows, diffs against the incoming set, and applies only the delta. The diff key is (target_type, target_id, relation_kind). When the diff is empty, the common case, the function returns after one SELECT and zero writes.
This is the fastest layer. It runs synchronously on the save path and keeps the graph consistent with no background job required. It only covers runtime-defined content types with a schema field marked relation: edge. Compiled content types (via Module) can call store.RecomputeAsserted directly.
Node.Rev and optimistic concurrency
Content Relations also introduced Node.Rev, a monotonically increasing integer on every content node. The storage layer increments it on every save after the first (Rev starts at 0 on insert). If two goroutines read the same node and both attempt to save, the second write returns ErrRevConflict rather than silently overwriting:
// Rev is incremented by the storage layer on every save after the first.
// Use it as an optimistic-concurrency token: if two goroutines read the same
// node (both see Rev = 3), the second Save returns ErrRevConflict instead
// of silently overwriting.
Rev int `db:"rev"`
This matters for the relation graph: an edge assertion that races with a delete of the target item is now detectable rather than silent.
The three-layer model
Layer 1 handles the common case: save an item, its relations are updated synchronously. But it cannot react to what happens to the target. If a referenced source item is archived, the save-path hook for the source item will not fire. That is Layer 2's job: reacting to target-side state changes with a cascade signal. And neither Layer 1 nor Layer 2 can catch everything. A bulk import, a direct database write, or a missed signal can leave stale edges in the graph. That is Layer 3's job: the periodic structural sweep. The next two posts in this series cover each of those layers in detail.
Reference: Content Relations