Relatude.DB.CMS
1.1.1.3-alpha
dotnet add package Relatude.DB.CMS --version 1.1.1.3-alpha
NuGet\Install-Package Relatude.DB.CMS -Version 1.1.1.3-alpha
<PackageReference Include="Relatude.DB.CMS" Version="1.1.1.3-alpha" />
<PackageVersion Include="Relatude.DB.CMS" Version="1.1.1.3-alpha" />
<PackageReference Include="Relatude.DB.CMS" />
paket add Relatude.DB.CMS --version 1.1.1.3-alpha
#r "nuget: Relatude.DB.CMS, 1.1.1.3-alpha"
#:package Relatude.DB.CMS@1.1.1.3-alpha
#addin nuget:?package=Relatude.DB.CMS&version=1.1.1.3-alpha&prerelease
#tool nuget:?package=Relatude.DB.CMS&version=1.1.1.3-alpha&prerelease
Relatude.CMS
A .NET 8 content backend layered on Relatude.DB, an in-process object-oriented graph database (NuGet Relatude.DB.Server). You model your domain as C# interfaces (the engine generates the implementing classes), declare relationships as small marker classes, and read/write through a NodeStore. Provides the base content model (IItem, IPage, ISite, ITemplate), dynamic URL routing, and DB access plumbing.
Using an AI coding agent? This package ships a Claude Code skill (
relatude-cms) with the same guidance below. The commerce layer is documented inRelatude.Ecom(relatude-ecomskill), which builds on this one.
Host setup (Program.cs)
Relatude.DB runs in-process inside an ASP.NET Core app:
var builder = WebApplication.CreateBuilder(args);
builder.AddRelatudeDB(); // from Relatude.DB.Server, no namespace needed
builder.Services.AddHttpContextAccessor();
// register CMS services (routing, URL service, request context) here
var app = builder.Build();
app.UseRelatudeDB(); // starts the engine; admin UI at /relatude.db
app.Run();
AddRelatudeDB registers a DI type Database : NodeStore (transient) you can inject directly. The schema (datamodel) is managed through the admin UI at /relatude.db, where you point a datamodel source at the assembly/types holding your model interfaces.
Getting a NodeStore
NodeStore (namespace Relatude.DB.Nodes) is the entire data API. Obtain one by injecting Database, by injecting IRelatudeDBAccessor (Relatude.CMS.Engine) and using .Database, or statically via RelatudeDBRuntime.Server. Convention: methods that touch data take NodeStore db as a parameter rather than capturing it.
Modeling: interfaces, not classes
An entity is an interface extending IItem (and usually IPage for routable content):
public interface IPage : IItem {
string MetaTitle { get; set; }
string MetaDescription { get; set; }
TemplatePages.Template Template { get; set; } // relation end → the one Template for this page
}
IItem gives every node Guid Id, string Name, NodeMeta Meta (Meta.Address is the URL slug). IPage adds SEO metadata and a Template relation end. Other base types: ISite, ITemplate, ITree, ICountry, ICMSUser, ICmsUserGroup.
Relations are declared as classes deriving from OneToMany<,>, ManyToMany<,>, OneToOne<,>, OneOne<>, or ManyMany<>, with nested marker classes naming each end:
public class TemplatePages : OneToMany<ITemplate, IPage> {
public class Template : One { } // exposed on IPage
public class Pages : Many { } // exposed on ITemplate
}
OneToMany<TOne,TMany> exposes One/Many; ManyToMany<TFrom,TTo> exposes ManyFrom/ManyTo; OneToOne<,> exposes OneFrom/OneTo. Attributes (Relatude.DB.Nodes) tune mapping: [Node(...)] on the interface, [Exclude], and per-type property attributes ([StringProperty], [IntegerProperty(Indexed=true, UniqueValues=true)], [DecimalProperty], [GuidProperty], …). Embedded value maps use [EmbeddedMapProperty(KeyProperty = ...)].
Navigating relations on a loaded node
Relation ends are objects, not plain collections:
if (page.Template.IsSet()) { … } // One: has value? / Many: Count() > 0
var template = page.Template.Get(); // One → T (throws if not set; guard first)
page.Template.TryGet(out var t); // safe variant
foreach (var child in node.Children.Get()) // Many → IEnumerable<T>
In queries prefer Include(...) over lazy .Get() to avoid N+1.
CRUD through NodeStore
var page = db.Create<IPage>(); // in-memory, NOT yet persisted
db.Insert(page); // or CreateAndInsert<IPage>(p => { ... })
db.Update(existing); // UpdateOrFail / UpdateIfExists / Upsert / ForceUpsert
db.Delete(node); // also Delete(Guid id) / Delete(IEnumerable<Guid>)
var p = db.Get<IPage>(id); // throws if missing
db.TryGet<IPage>(id, out var page2); // safe
bool exists = db.Exists<IPage>(id);
Relations are set/cleared as their own operations (property-selector lambda):
db.SetRelation(page, p => p.Template, templateId); // one-end: replaces
db.AddRelation(template, t => t.Pages, page); // add to many-end
db.RemoveRelation(template, t => t.Pages, page); // remove one link
db.ClearAndSetRelation(node, x => x.Many, newSet); // replace whole set
Write methods accept flushToDisk (default false) and have …Async counterparts. Mutating a property on a loaded node still requires an Update call to persist.
Querying
db.Query<T>() returns IQueryOfNodes<T,T> — fluent and expression-based; nothing runs until a terminal call:
var results = db.Query<IPage>()
.Where(p => p.MetaTitle != null)
.Include(p => p.Template) // eager-load; ThenInclude to go deeper
.OrderBy(p => p.Name)
.Page(0, 20) // pageIndex0based, pageSize (or Take/Skip)
.Execute(); // ResultSet<IPage>; .ToList()/.ToArray() also
var one = db.Query<IPage>().Where(p => p.Meta.Address == slug).FirstOrDefault();
long n = db.Query<IPage>().Count();
Also: WhereSearch/Search (BM25 + optional semantic/vector), WhereRelates/WhereNotRelates/WhereRelatesAny, WhereIn, WhereTypes, Facets(), Sum(...), SelectId(), a string-based Where("a => …") overload, and db.TryGetFromAddress<IPage>(address, out var page) for URL → node. Query context controls culture/visibility/revisions: db.Context.Culture("nb-NO").Hidden().Admin().Create(). Default queries exclude hidden nodes.
Transaction plugins (triggers)
Cross-cutting node behavior goes in a plugin. Derive NodeTransactionPlugin<T>, override OnBeforeNodeAction / lifecycle hooks, register with db.RegisterTransactionPlugin(...). Example: UrlAddressPlugin : NodeTransactionPlugin<IPage> generates a unique URL slug on upsert via transaction.UpdateAddress(...). Switch on the NodeOperation enum (Upsert, Insert*, Update*, ForceUpsert, Delete*).
Dynamic URL routing (Relatude.CMS.Engine.Web)
RelatudeDynamicRouting : DynamicRouteValueTransformermaps URL →{controller, action, id}viaIRelatudeAddressResolver(→RequestInfo), with per-request state inIRelatudeRequestContext.IRelatudeUrlService.GetUrl(IPage)reverses it: storedMeta.Address, then a template-based action link, then/rnode/{id}fallback.RelatudeUrlHelper.GetUniqueUrlSlug(name, db)generates collision-free slugs.
CMS settings and analytics (Relatude.CMS.Settings, Relatude.CMS.Analytics)
builder.Services.AddRelatudeCms() registers the CMS's own settings, CmsSettings, as a Relatude.DB custom settings set: a CMS tab beside each database's settings in the admin UI, read from code as db.Settings.Get<CmsSettings>(). It is layered like every custom setting: relatude.settings/cms.json (SHARED), the admin UI's saves (THIS SERVER), then RelatudeDB:CustomSettings:cms in appsettings. Its first group is Analytics; more CMS settings go in the same class.
builder.AddRelatudeDB();
builder.Services.AddRelatudeCms();
var app = builder.Build();
app.UseRelatudeDB();
app.UseRelatudeCmsAnalytics(); // after UseRelatudeDB, before the site's pages and static files
Analytics is off until turned on (CMS > Analytics > Record page views). Then:
- Every page view is recorded in the custom log
cms-pageviews("Page views" under Logs in the admin UI, with graphs from its statistics): path, host, referrer host, visitor hash, visitor type (human,bot,unknown), bot name, country, region, city, position, browser, operating system, device, language and duration. A page view is a GET answered 200 with HTML. The admin UI,/-info/, the paths inExcludedPathsand prefetches are never page views. - The log is the database's once it is made. It is created when it is missing - the first time analytics is on, and again if it is deleted in the admin UI. After that, changes made in the admin UI (retention, columns taken out, statistics, switches) are kept. The code only adds columns: each column in
PageViewLogcarries theDefinitionVersionthat introduced it, and the database's storage remembers the version its log has (cms/pageviews-log.json). When the CMS is newer, the columns added since are put into the log if missing, once; a column removed afterwards stays removed. A log made before this file existed counts as version 1. To add a column, give it the next version and raiseDefinitionVersion. - Nothing happens while the visitor waits. The middleware only queues the page view (
PageViewQueue).AnalyticsWorker, a hosted service, waitsLookupDelaySeconds(5) for the browser's bot check to report, looks the visitors of a batch up at once withdb.Services.IPLookup(once per address and user agent; settled verdicts are kept for 10 minutes), and records the entries. A failed lookup records the page views without it and warns once per 10 minutes. - The bot check is added to every HTML page just before its last
</body>, as the CMS'sHtmlRewriterfinds it (InjectBotCheck,InlineBotCheck). Only an uncompressed UTF-8 HTML 200 of at most 16 MB is held to do it; everything else streams through untouched. CallUseResponseCompression()beforeUseRelatudeCmsAnalytics(), if at all. - No cookie and no address are kept (GDPR). The visitor is the daily visitor hash,
SHA-256(IP + User-Agent + Language + daily salt)(DailyVisitorHash). The salt is 32 random bytes per UTC day, held in memory only and never written, so a hash cannot be traced back or linked across days. A process restart starts a new salt, so a visitor seen on both sides of it counts twice that day. The referrer is only its host, and the query string is left out unlessLogQueryStringis on. IAnalyticsHostis the database side (settings, custom logs, the database's storage, IP lookups, reserved paths). The default isRelatudeAnalyticsHost, the default database's; tests replace it.- Behind a proxy, call
app.UseForwardedHeaders()first, so the address hashed and looked up is the visitor's.
Donations (Relatude.CMS.Models.Donations)
A generic donation node model with the list/detail projection behind the WAF.Client Donations admin module (postWebAPI(session, "Donations", "List" | "Get", …)). The model namespace holds only the IDonation interface — a datamodel source scans it, so anything else in that namespace would be registered as a node type. The wire DTOs (DonationRow/DonationListResult/DonationDetail) and the pure DonationAdminProjection live in Relatude.CMS.Models.Donations.Admin. Sites extend the model with their own node interface (public interface IMyDonation : IDonation { … }) — a member may be declared in only one interface of the hierarchy.
Both namespaces are registered as datamodel sources — matching is exact, and an unregistered base interface silently drops all inherited fields from the derived type:
"DatamodelSources": [
{ "Id": "<unique guid>", "Name": "My site", "Namespace": "MySite.Models", "Type": "AssemblyNameReference", "Reference": null },
{ "Id": "<unique guid>", "Name": "Donations", "Namespace": "Relatude.CMS.Models.Donations", "Type": "AssemblyNameReference", "Reference": "Relatude.DB.CMS" }
]
The admin controller stays in the host — it derives from the host framework's admin API base (e.g. WAF's BaseAdminAPIController), which this package does not reference. Routed as wafapi/Donations/{action}, the shape is:
[HttpPost]
public DonationListResult List(int pageSize = 25, int pageIndex = 0, string? sortBy = null, bool sortDesc = true,
string? categories = null, string? statuses = null, string? paymentMethods = null, string? search = null) {
_ = Context.Session; // WAF's admin gate — throws (→ 403) without a session
var query = _cx.Database.Query<IMyDonation>() // WhereIn(d => d.Status/…) for non-empty filters
.OrderBy(DonationAdminProjection.SortExpression<IMyDonation>(sortBy), sortDesc);
var page = query.Page(pageIndex, pageSize).Execute();
return new DonationListResult { Rows = page.Values.Select(d => DonationAdminProjection.ToRow(d)).ToList(), TotalCount = page.TotalCount };
}
Get(id) is TryGet<IMyDonation> → DonationAdminProjection.ToDetail(d, formGuid: …), where the host resolves the originating form's content guid from IDonation.FormId (see DonationDetail.FormGuid — the admin client cannot query by ContentId). The host also snapshots the form's name onto IDonation.FormName when the donation is created — denormalized on purpose, since the list sorts and searches it server-side (AQL orders node properties, not values computed at projection time); a later rename therefore does not retro-rename existing donations. Category (onetime/recurring/business) and free-text search have no AQL form — when active, materialise the ordered set, filter with MatchesCategory/MatchesSearch, then page.
Notes
Create<T>()does not persist — follow withInsert/CreateAndInsert; callUpdateafter mutating a loaded node.- Relation ends aren't
Lists — use.Get()/.IsSet()/.TryGet(). - Relation changes go through
SetRelation/AddRelation/RemoveRelation, not property assignment. - This is a library with no entry point; engine start and schema live in the host app and the
/relatude.dbadmin UI. - Relatude.DB source/API reference: https://github.com/Relatude/Relatude.DB
- Donations: every
DatamodelSourcesentry needs its own uniqueId— the store refuses to boot without one. - Donations: the enum-like string values (
PaymentMethod,Status,GiftType,Frequency) are matched case-sensitively by the admin module's filters — store exactly the values documented onIDonation(all lowercase, e.g.vippsrecurring).
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Relatude.DB.Server (>= 1.1.1.3-alpha)
NuGet packages (2)
Showing the top 2 NuGet packages that depend on Relatude.DB.CMS:
| Package | Downloads |
|---|---|
|
Relatude.Core
Relatude .Net 10.0 Headless CMS and E-commerce solution |
|
|
Relatude.DB.Ecom
Relatude E-commerce backend API for Relatude.DB. Builds on the Relatude.CMS package. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 1.1.1.3-alpha | 54 | 10/17/2026 |
| 1.0.44-alpha | 114 | 10/12/2026 |
| 1.0.43 | 183 | 10/5/2026 |
| 1.0.42 | 155 | 9/23/2026 |
| 1.0.41 | 105 | 9/23/2026 |
| 1.0.40 | 101 | 9/23/2026 |
| 1.0.39 | 178 | 9/7/2026 |
| 1.0.38 | 175 | 9/1/2026 |
| 1.0.37 | 195 | 8/26/2026 |
| 1.0.36 | 135 | 8/21/2026 |
| 1.0.35 | 142 | 8/18/2026 |
| 1.0.34 | 128 | 8/17/2026 |
| 1.0.33 | 138 | 8/17/2026 |
| 1.0.32 | 176 | 8/5/2026 |
| 1.0.31 | 139 | 8/4/2026 |
| 1.0.30 | 125 | 8/3/2026 |
| 1.0.29 | 129 | 7/26/2026 |
| 1.0.28 | 136 | 7/26/2026 |
| 1.0.1.3-alpha | 42 | 10/16/2026 |
| 1.0.1.2-alpha | 54 | 10/15/2026 |