AngryMonkey.CDM.ExternalClient 8.0.1

dotnet add package AngryMonkey.CDM.ExternalClient --version 8.0.1
                    
NuGet\Install-Package AngryMonkey.CDM.ExternalClient -Version 8.0.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="AngryMonkey.CDM.ExternalClient" Version="8.0.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="AngryMonkey.CDM.ExternalClient" Version="8.0.1" />
                    
Directory.Packages.props
<PackageReference Include="AngryMonkey.CDM.ExternalClient" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add AngryMonkey.CDM.ExternalClient --version 8.0.1
                    
#r "nuget: AngryMonkey.CDM.ExternalClient, 8.0.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package AngryMonkey.CDM.ExternalClient@8.0.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=AngryMonkey.CDM.ExternalClient&version=8.0.1
                    
Install as a Cake Addin
#tool nuget:?package=AngryMonkey.CDM.ExternalClient&version=8.0.1
                    
Install as a Cake Tool

Cloud Data Management (CDM) - Complete Reference

CDM is the AngryMonkey Cloud framework for building data-driven portal applications on top of Azure Cosmos DB and Azure Blob Storage. It provides a code-generation builder, a metadata-driven form/view engine, role-based security, search, audit logging, and a full Blazor UI - all from a single, unified configuration.


Table of Contents

  1. Architecture Overview
  2. NuGet Packages
  3. Configuration - CDMSettings
  4. Server Setup
  5. Builder Project
  6. Custom Domain - Overriding Entity Methods
  7. Form Action Handler
  8. Security & Permissions
  9. Search
  10. Settings Module
  11. Engine Advanced Features
  12. Complete Sample - BluSky Portal
  13. Common Pitfalls
  14. External Client - CDM.ExternalClient
  15. Aspire Integration

1. Architecture Overview

+--------------------------------------------------+
|              Blazor Portal UI                    |
|  (generated forms, views, dashboard, settings)   |
+------------------------+-------------------------+
						 |
+------------------------v-------------------------+
|              CDM Engine Layer                    |
|  CDMEngineBase  <------ Custom Domain            |
|  (routing, metadata, security, audit, sequences) |
+--------+---------------------------------+--------+
		 |                                 |
+--------v-----------+    +----------------v-------+
|   Azure Cosmos DB  |    |   Azure Blob Storage   |
|  (data + metadata) |    | (files, images, audit) |
+--------------------+    +------------------------+

Typical project layout

MyApp.Portal.Builder/        <- runs once; generates everything below
MyApp.Portal.DataContract/   <- entity model classes
MyApp.Portal.Data.Cosmos/    <- auto-generated Cosmos persistence layer
MyApp.Portal.Engine/         <- auto-generated engine + your overrides
MyApp.Portal.Client/         <- auto-generated HTTP client
MyApp.Portal/                <- ASP.NET Core host + auto-generated API controllers
MyApp.Portal.WebAssembly/    <- Blazor WASM (optional)

Files inside auto-generated/ folders are fully managed by the builder. Never edit them manually - regenerate with the builder instead.


2. NuGet Packages

Package Used in
AngryMonkey.CDM.Models The entity/field-type model every other package builds on
AngryMonkey.CDM.Config CDMSettings, Cosmos/Storage/security/audit-log configuration
AngryMonkey.CDM.Builder Builder project only - defines entities, forms, views, security
AngryMonkey.CDM.Components The generated Blazor UI (forms, views, dashboard, settings)
AngryMonkey.CDM.Server Portal host project - AddCDMServer
AngryMonkey.CDM.Server.WebAssembly WebAssembly runtime used by CDM.Server's standalone UI
AngryMonkey.CDM.Client Blazor WASM and other external consumers
AngryMonkey.CDM.ExternalClient Standalone API clients with no Blazor/component dependency - see §14
AngryMonkey.CDM.Aspire.Hosting AppHost-side: adds a CDM server to a .NET Aspire distributed application - see §15
AngryMonkey.CDM.Aspire Server-side: binds CDMSettings from what the AppHost wired - see §15

3. Configuration - CDMSettings

CDMSettings is the root configuration object. Bind it from appsettings.json under the "CDM" key.

BaseName (optional) - one name drives every database/storage name

BaseName is optional. Leave it unset and CDM behaves exactly as before — every database/container/table name must be configured individually, falling back to CDM's original fixed defaults ("storage", "uploads", "metadata", ...) for anything left unset, with Cosmos.DatabaseName still required.

Set BaseName and every Cosmos database, Blob container, and Table Storage table name derives from it instead (see CDMResourceNaming for the exact rules — it's a single file, so change the convention there if you need a different one). Setting BaseName to "MyProject" gives you:

Resource Derived name
Cosmos database MyProject
Blob container MyProject-storage
Uploads container MyProject-uploads
Metadata table MyProjectmetadata
Security table MyProjectsecurity
Lookup table MyProjectlookups
Geography table MyProjectgeography
Sequence table MyProjectsequences
Tracking (change log) table MyProjectchanges
Audit log table MyProjectlogs

Use a different BaseName per environment ("MyProject-Dev", "MyProject-Staging", "MyProject") to get an isolated set of resources per environment from a single appsettings.{Environment}.json override. For a family of related portals (e.g. MelonCut Guidelines and MelonCut Design), give each its own BaseName ("MelonCut-Guidelines", "MelonCut-Design") so they never collide.

Any name set explicitly (e.g. Cosmos.DatabaseName, Storage.ContainerName) always wins over the value derived from BaseName.

appsettings.json

{
  "CDM": {
	"BaseName": "MyProject",
	"Title": "My Portal",
	"LoginUrl": "https://login.example.com/",
	"Cosmos": {
	  "ConnectionString": "<cosmos-connection-string>"
	},
	"Storage": {
	  "ConnectionString": "<storage-connection-string>",
	  "UploadsRetentionHours": 24,
	  "UploadsCleanupIntervalMinutes": 60
	},
	"Security": {
	  "IsRoleBased": true,
	  "IsVertical": false
	},
	"AuditLog": {
	  "Enabled": true
	},
	"Theme": {
	  "AccentColor": "#0c75c4"
	},
	"Maps": {
	  "AzureSubscriptionKey": "<azure-maps-key>",
	  "ValidateAddresses": true
	}
  }
}

When BaseName is set, everything under Cosmos/Storage/AuditLog besides ConnectionString and the upload-retention numbers becomes optional — omit a name to get the BaseName-derived default, or set it explicitly to override just that one resource. Without BaseName, Cosmos.DatabaseName must still be set explicitly, same as always.

Configuration binding in Program.cs

CDMSettings settings = new()
{
	Title = "My Portal",
	Theme = new() { AccentColor = "#0c75c4" },
	AuditLog = new() { Enabled = true }
};

// Define security roles before binding (so code-defined roles are not overwritten)
settings.Security.IsRoleBased = true;
settings.Security.PreconfiguredRoles.Add(new() { Code = "Admin",  Name = "Administrator" });
settings.Security.PreconfiguredRoles.Add(new() { Code = "Editor", Name = "Editor" });

// Bind remaining values from appsettings (BaseName, Cosmos, Storage, LoginUrl ...)
builder.Configuration.GetSection("CDM").Bind(settings);

CDMSettings properties

Property Type Description
BaseName string? Optional base name for all databases/storage resources for this deployment; see above
Title string Portal display name
LoginUrl string Base URL of the CloudLogin server
Cosmos CDMCosmosSettings Cosmos DB connection
Storage CDMStorageSettings Azure Storage connection + table names
Security CDMSecuritySettings Role-based / vertical access flags
AuditLog CDMAuditLogSettings Enable audit log + storage settings
Theme CDMThemeSettings UI accent color
Maps CDMMapsSettings Azure Maps key + address validation toggle

4. Server Setup

Program.cs - minimal setup

using CDM;
using AngryMonkey.Cloud.CDM;
using MyApp.Portal;
using MyApp.Portal.DataContract;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

CDMSettings settings = new() { Title = "My Portal" };
settings.Security.IsRoleBased = true;
settings.Security.PreconfiguredRoles.Add(new() { Code = "Admin", Name = "Administrator" });
builder.Configuration.GetSection("CDM").Bind(settings);

// Registers engine, domain, Cosmos client, storage, security, and all CDM middleware
builder.AddCDMServer<MyAppEngine, MyAppCosmos, MyAppDomain>(settings);

var app = builder.Build();

// Mounts CDM middleware, routing, SignalR hub, and static assets
app.UseCDM<MyAppDomain>();

await app.RunAsync();

AddCDMServer signature

builder.AddCDMServer<TEngine, TCosmos, TDomain>(CDMSettings settings, Action<CDMConfig<TDomain>>? configAction = null);
  • TEngine - your engine class (extends the auto-generated DefaultXxxEngine)
  • TCosmos - your Cosmos class (auto-generated from builder)
  • TDomain - your domain class (extends the auto-generated DefaultXxxDomain)

CDMConfig<TDomain> (optional configure action)

Property Type Description
CustomDomain TDomain? Pre-built domain instance, when you need to construct it yourself instead of letting CDM resolve it from DI
WebConfig Action<CloudWebConfig>? Configures the underlying CloudBlazor host (page title, favicon, indexing, bundles, ...)
Settings Action<CDMSettingsBuilder>? Registers the Settings module (Security roles, Addresses, custom areas)
builder.AddCDMServer<MyEngine, MyCosmos, MyDomain>(settings, config =>
{
	config.WebConfig = web =>
	{
		web.PageDefaults.SetTitle("My Portal");
		web.PageDefaults.SetFavicon("favicon.svg");
		web.PageDefaults.SetIndexPage(false);
		web.PageDefaults.SetFollowPage(false);
	};

	// Register the Settings module (Security roles, Addresses ...)
	config.Settings = s => s
		.HasRoleBasedAccessSettings(engine.Security.DataStorage)
		.HasAddressesSettings(new MyAddressOperations());
});

Titles, favicon, indexing, and CDM's own CSS bundle already get sensible defaults (see AddCDMServer's implementation in CDM.Server/ServiceExtensions.cs) if WebConfig is left unset or only sets some of them.

WebAssembly hosting (CDM.Server.WebAssembly)

The optional MyApp.Portal.WebAssembly project hosts the interactive-WebAssembly render mode. Its whole Program.cs is one call:

// Program.cs of the WebAssembly project
using CDM;

await CDMWebAssembly.RunAsync(args);

CDMWebAssembly.RunAsync builds a WebAssemblyHostBuilder, calls services.AddCDMServerWebAssembly(baseAddress), and runs the host. That extension method:

  • Creates a CDMClient pointed at the host's own base address and fetches the module metadata (Module) and the Maps configuration once at startup — the Azure Maps key is never baked into the published WASM assets; it is released only to authenticated sessions.
  • Registers AddHttpRoleLoader() as the WebAssembly IRoleLoader (the server instead uses ServerRoleLoader, registered by AddCDMServer).
  • Calls the same AddCDMComponents() used by the server, so field renderers, FileUploadCoordinator, CDMAccountService, and the custom field registry behave identically on both render targets. WebAssembly always keeps the default IAccessPolicy it registers — it exists for UI hints only; real permission enforcement always happens on the server.
  • Builds the SignalR HubConnection used by the Form Action Handler (/actionHub), serialized with CDMSerialization.Options.

Register any WebAssembly-only custom field components before RunAsync returns control, from inside your own copy of AddCDMServerWebAssembly or a follow-up call to services.AddCustomFieldComponent<TComponent>("Key") (see Fields.Custom).

AddCDMComponents (shared component services)

Everything the CDM Razor components resolve at runtime and that is identical on the server and in WebAssembly — FileUploadCoordinator, CDMAccountService, IAccessPolicy, CloudGeographyClient, the custom field registry — is registered by a single method:

services.AddCDMComponents();

AddCDMServer and AddCDMServerWebAssembly already call it, so a normal portal needs nothing extra. Call it explicitly only from a host that composes its own container and renders CDM components without going through those two entry points — this keeps that host working when new component services are added to CDM.

Every registration uses TryAdd, so anything you register before calling it wins. Host-specific services are not covered and must still be registered separately: IRoleLoader (ServerRoleLoader on the server, AddHttpRoleLoader() on WebAssembly), CDMClient, Module, and the SignalR HubConnection.

CORS (optional)

builder.Services.AddCors(options =>
	options.AddPolicy("PortalCors", policy =>
		policy.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod()));

// ...
app.UseCors("PortalCors");
app.UseCDM<MyAppDomain>();

5. Builder Project

The builder is a console application that reads your entity definitions and generates the entire Cosmos, Engine, DataContract, Client, and Portal auto-generated code. Run it whenever you change entity structure.

5.1 Creating the Builder

// Program.cs of the Builder project
using AngryMonkey.Cloud.CDM;
using AngryMonkey.Cloud.CDM.Models;
using MyApp.Builder.Entities;

var builder = new CDMBuilder("MyApp.Portal");

// Optional: set module-level defaults
builder.Module.DefaultCountryCode = "US";

// Register entities and collection item sets through the same API.
// Collection item schemas referenced by a parent are also discovered automatically.
builder.Entity<SocialMediaLink>();
builder.Entity<Contact>();
builder.Entity<Property>();
builder.Entity<Region>();

// Dashboard (see section 5.10)
builder.HasDashboard()
	.HasDashboardView<Contact>()
	.HasDashboardView<Property>();

// Centralized security (see section 5.11)
builder.HasSecurity<MySecurityConfig>();

// Generate
await builder.GenerateAndReplaceFiles(new CDMBuilderOptions
{
	CreateMainProjects = false
});

CDMBuilder constructor:

new CDMBuilder(
	schemaName: "MyApp.Portal",   // used as namespace prefix and folder name
	domain: null,                  // defaults to schemaName
	defaultCurrency: "USD"         // used by Money fields
)

5.2 Entity Definition - EntitySet

Every entity is a class that inherits EntitySet. Public properties of type Field (or any subclass) become the entity's fields.

using CDM;
using CDM.Builder;
using AngryMonkey.Cloud.CDM.Models;

[Entity("Contact", "Contacts")]   // singular and plural display names
public class Contact : EntitySet
{
	public Fields.SingleLine FirstName { get; set; } = new()
	{
		Required = RequiredField.Required
	};

	// The primary field drives default search and record title display
	public Fields.SingleLine Name { get; set; } = new()
	{
		IsPrimary = true
	};

	public Fields.SingleLine PhoneNumber { get; set; } = new(Fields.SingleLineValidation.PhoneNumber)
	{
		Required = RequiredField.Required
	};

	// Override Forms and Views (sections 5.6 and 5.7)
	protected override FormConfig[] Forms => [ /* ... */ ];
	protected override ViewConfig[] Views => [ /* ... */ ];
}

If you omit Forms / Views overrides, the builder auto-generates a default form (all fields) and a default view (primary field only).


5.3 Field Types

All field types live in the static Fields class inside AngryMonkey.Cloud.CDM.Models.

Fields.SingleLine

Plain text input with optional built-in validation.

// Plain text
public Fields.SingleLine Name { get; set; } = new();

// With built-in validation
public Fields.SingleLine Email   { get; set; } = new(Fields.SingleLineValidation.Email);
public Fields.SingleLine Website { get; set; } = new(Fields.SingleLineValidation.Url);

// Phone number with dial-code selector
public Fields.SingleLine Phone { get; set; } = new(Fields.SingleLineValidation.PhoneNumber)
{
	PhoneDefaultCountryCode = "LB"   // seeds the country dial-code picker
};

// Custom regex validation
public Fields.SingleLine PostalCode { get; set; } = new(Fields.SingleLineValidation.Regex)
{
	ValidationPattern    = @"^\d{5}$",
	ValidationErrorMessage = "Must be a 5-digit ZIP code"
};

// Autocomplete from an option set or runtime data source
public Fields.SingleLine City { get; set; } = new()
{
	Behavior              = FieldBehavior.Autocomplete,
	AutocompleteCategory  = "Cities",
	AutocompleteAllowCreate = true
};

SingleLineValidation values: None, Email, PhoneNumber, Url, Regex

Fields.MultipleLines

Multi-line textarea.

public Fields.MultipleLines Notes { get; set; } = new() { FullWidth = true };
Fields.WholeNumber

Integer input.

public Fields.WholeNumber BedroomCount { get; set; } = new() { DefaultValue = 1 };
Fields.DecimalNumber

Decimal number input.

public Fields.DecimalNumber AreaSize { get; set; } = new()
{
	Label       = "Area Size (m2)",
	Required    = RequiredField.Required,
	Description = "Enter the area in square meters"
};
Fields.Money

Currency amount. Uses the builder's DefaultCurrency.

public Fields.Money AskingPrice { get; set; } = new()
{
	Label       = "Asking Price",
	Description = "Public listing price"
};
Fields.Boolean

Checkbox / toggle.

public Fields.Boolean IsActive  { get; set; } = new() { DefaultValue = true };
public Fields.Boolean MainRoad  { get; set; } = new();
Fields.Date

Date picker.

public Fields.Date ClosingDate { get; set; } = new();
Fields.Predefined

Dropdown / buttons / autocomplete backed by a named option set (section 5.9).

// Reference a named option set by its schema name
public Fields.Predefined Status { get; set; } = new("ContactStatus")
{
	Required     = RequiredField.Required,
	DefaultValue = "Active",              // must match an OptionSetOption.Key
	Behavior     = FieldBehavior.DropDown // DropDown | Autocomplete | Buttons
};

// Multiple selection
public Fields.Predefined Amenities { get; set; } = new("AmenitiesOptionSet")
{
	MultipleValue = true
};

// Buttons rendering
public Fields.Predefined Estimated { get; set; } = new("EstimatedOptionSet")
{
	Behavior     = FieldBehavior.Buttons,
	DefaultValue = "Priced"
};

FieldBehavior values for Predefined: DropDown, Autocomplete, Buttons

Fields.OnDemand

Options loaded at runtime by the Form Action Handler. The option list is not embedded in metadata - it is pushed by calling FormActionResult.ChangeOptions(...) from your action handler.

// Single selection, shown as dropdown
public Fields.OnDemand Broker { get; set; } = new()
{
	Behavior      = FieldBehavior.DropDown,
	MultipleValue = false
};

// Multiple selection, shown as picker
public Fields.OnDemand AssignedUsers { get; set; } = new()
{
	Behavior      = FieldBehavior.Picker,
	MultipleValue = true,
	AllowCreate   = false
};

FieldBehavior values for OnDemand: DropDown, Autocomplete, Picker, Buttons

Fields.EntityReference

Look-up to another entity record. Renders as a picker with an optional inline "New" form.

// Reference a custom entity
public Fields.EntityReference CoverImage { get; set; } = new("Media");

// Reference a system entity
public Fields.EntityReference Owner { get; set; } = new(SystemEntityReference.User);

// Declarative filters on the picker
public Fields.EntityReference City { get; set; } = new("City")
{
	Behavior             = FieldBehavior.Picker,
	AllowNew             = true,
	DisplayFieldSchemaName = "Name",
	Filters =
	{
		new() { FieldSchemaName = "IsActive", Value = true },
		new() { FieldSchemaName = "Country",  Value = "00000000-0000-0000-0000-000000000001" }
	}
};

// Multiple references
public Fields.EntityReference Tags { get; set; } = new("Tag")
{
	MultipleValue = true,
	AllowNew      = false
};

FieldBehavior values: Picker (default), DropDown, Autocomplete, Buttons
SystemEntityReference values: Custom, User, Team
EntityReferenceFilterOperator values: Equals, NotEquals, GreaterThan, GreaterThanOrEqual, LessThan, LessThanOrEqual

Fields.Address

Structured address with per-sub-field control. Supports both single and multiple addresses (MultipleValue = true).

Two input modes (InputMode):

  • Structured (default) — a details popup with one input per enabled part.
  • Lookup — a single text input with autocomplete over the geography data (cities from the Table Storage city category, plus district- and subdivision-level entries generated from CloudGeography when the country is locked). AllowNewEntries controls whether unknown values can be added through the details popup (false = select-only from the settings-managed list).

The hierarchy is Country → Subdivision → SubdivisionChild (district) → City. IsRequired on those three levels defines the minimum selection depth, not independent per-part requiredness: a required City rejects district-only values; an optional City with a required SubdivisionChild accepts district-level values; and so on. Adding a new city always requires its full enabled ancestor chain regardless of the minimum depth.

Per-part Label overrides the built-in input label, and ShowInDisplay = false hides a part from the formatted display while keeping it stored and searchable (the deepest selected level is always shown).

public Fields.Address Address { get; set; } = new()
{
	Required = RequiredField.Required,
	Country  = new()
	{
		IsEnabled    = false,          // user cannot change the country
		IsVisible    = false,
		IsRequired   = true,
		DefaultValue = "LB"            // ISO-3166-1 alpha-2 code
	},
	Subdivision = new()
	{
		IsRequired = true,
		Codes = ["BA", "JL", "AS"]     // restrict to specific subdivisions
	},
	SubdivisionChild = new() { IsRequired = true },
	City             = new() { IsRequired = true },
	Line1            = new() { IsRequired = false },
	Line2            = new() { IsVisible = false },
	PostalCode       = new() { IsVisible = false },
	Map              = new() { IsRequired = false }
};

// Multiple address values (e.g. "preferred cities")
public Fields.Address PreferredCities { get; set; } = new()
{
	Label         = "Cities",
	MultipleValue = true,
	Country       = new() { IsEnabled = false, IsVisible = false, DefaultValue = "LB" },
	Subdivision   = new() { IsRequired = true },
	City          = new() { IsRequired = true }
};

AddressFieldOption properties: IsEnabled, IsVisible, IsRequired, DefaultValue, Codes, Label, ShowInDisplay
Fields.Address properties: InputMode (Structured | Lookup), AllowNewEntries

Outside CDM forms, the same configuration drives the standalone Blazor AddressPicker component (CDM.Components.Fields):

<AddressPicker Options="myFieldsAddress" @bind-Value="model.Address" />
Fields.Schedule

Weekly schedule grid (day-of-week rows with start/end times).

public Fields.Schedule WorkingHours { get; set; } = new()
{
	DayOfWeek   = new() { IsRequired = true },
	StartTime   = new() { IsRequired = true },
	EndTime     = new() { IsRequired = true },
	IsActive    = new() { IsVisible = true },
	Notes       = new() { IsVisible = false },
	AllowMultipleTimeSlotsPerDay = true,
	Show24HourFormat             = true
};
Fields.Collection<TItem>

An ordered collection of structured items stored directly inside its parent record. Collection items have reusable CDM field metadata and typed generated contracts, but no independent table/container, discriminator, CRUD endpoint, search result, audit record, or relationship. Permissions inherit from the parent by default and can optionally be overridden for the item.

First define the collection item schema:

[CollectionItem("Social Media Link", "Social Media Links")]
public class SocialMediaLink : CollectionItemSet
{
	public Fields.SingleLine Title { get; set; } = new()
	{
		IsPrimary = true,
		Required = RequiredField.Required
	};

	public Fields.SingleLine Link { get; set; } = new()
	{
		Required = RequiredField.Required,
		Validation = Fields.SingleLineValidation.Url
	};

	public Fields.Boolean Hidden { get; set; } = new()
	{
		DefaultValue = false
	};
}

builder.Entity<Business>();
builder.Entity<SocialMediaLink>(); // Optional; auto-detected from Business too.

The builder discovers SocialMediaLink automatically from the parent's Fields.Collection<TItem> field. If you want to register or configure it explicitly, use the same Entity<T>() API; the builder detects CollectionItemSet and emits parent-owned collection-item metadata instead of independent storage or CRUD:

builder.Entity<SocialMediaLink>()
	.HasPermission("ContentEditor", EntityPermissions.All)
	.HasPermission("Viewer", EntityPermissions.Read);

Once a collection-item permission is explicitly configured, roles without a matching rule have no access to that item. An item edit grant permits a save restricted to that parent collection field; it does not grant update or delete permission on the parent record.

Then add the parent field:

public Fields.Collection<SocialMediaLink> SocialMediaLinks { get; set; } =
	new()
	{
		AllowReordering = true,
		AllowDuplicates = true,
		MinimumItems = 0,
		MaximumItems = 20
	};

File-backed child schemas can opt into a gallery summary. Configure the child entity once; the same presentation is used when it is stored in a collection or displayed through a one-to-many relationship:

public class MediaAsset : CollectionItemSet
{
	protected override EntityCollectionOptions CollectionOptions => new()
	{
		Type = CollectionType.ImagesAndVideos,
		PrimaryFileFieldSchemaName = nameof(Source),
		PrimaryTitleFieldSchemaName = nameof(Title),
		FileOptions = new FileFieldOptions
		{
			// Optional: MIME category and extension restrictions are combined.
			AcceptedExtensions = ["jpg", "jpeg", "png", "webp", "mp4", "mov"]
		}
	};

	public Fields.File Source { get; set; } = new();
	public Fields.SingleLine Title { get; set; } = new() { IsPrimary = true };
}

public Fields.Collection<MediaAsset> Media { get; set; } = new();

Supported collection types are Files, Documents, Images, Videos, ImagesAndVideos, Colors, and Fonts. Each file-backed type supplies its MIME restriction automatically; FileOptions.AcceptedExtensions can narrow it further. Fonts accepts WOFF, WOFF2, TTF, and OTF files. Colors uses PrimaryColorFieldSchemaName, which must point to a Fields.Color; its gallery card renders either the configured solid color or CSS gradient. Collections without a usable preview render No preview available.

Fields.Color persists one string value and provides a reusable color studio next to the text input. The CloudComponents popup supports solid colors, multi-stop linear/radial/conic gradients, angle and stop controls, and advanced CSS values such as oklch(...), color-mix(...), CSS variables, or repeating gradients. Validation rejects URLs, declarations, and unsafe or malformed CSS.

The collection options are applied to every file field on the child entity. If the primary field names are omitted, the builder uses the first file field and the field marked IsPrimary. A normal EntitySet can override the same CollectionOptions; when it is the many side of a one-to-many relationship, the form renders the same gallery while each child remains an independently saved CDM record.

Collection-item form fields can be shown conditionally:

new FormFieldConfig(nameof(Source))
{
	IsVisibleByDefault = false,
	VisibleWhenFieldSchemaName = nameof(Type),
	VisibleWhenValues = ["Image", "Video", "Document"]
}

The editor reevaluates the condition when the controlling field changes. Hidden fields are excluded from required-field validation, so a required file does not block saving an collection item type that does not use a file.

Generated contracts for these entities expose a derived FileType property with a MediaFileType value (Image, Video, Audio, Document, File, or Unknown). It is inferred from the configured primary file's MIME type and extension, is available to API and library consumers, and is ignored by the Cosmos/Newtonsoft storage contract. Runtime-only IsImage, IsVideo, IsAudio, and IsDocument properties are generated for convenience and are also excluded from storage. Legacy Boolean fields with those names on a file-backed builder entity are ignored, so they can no longer become persisted Cosmos properties.

The value is saved atomically as an ordered JSON array on the business record. Each item has an internal key used to identify it during editing and reordering; that key is not an independent CDM record ID.

Changes inside an collection item invoke the parent form action handler. The handler receives the complete collection through request.Output.GetCollectionItems<SocialMediaLink>() and scoped information about the triggering item:

IReadOnlyList<SocialMediaLink> links =
	request.Output.GetCollectionItems<SocialMediaLink>(nameof(Business.SocialMediaLinks));

CollectionItemActionContext? change = request.CollectionItemContext;
// change.CollectionFieldSchemaName
// change.CollectionItemSchemaName
// change.ItemKey
// change.ChangedFieldSchemaName
// change.ChangeType

Collection item schemas support persisted value fields—including files—and their normal validation, defaults, and form metadata. Entity references, custom sections, and relationship subgrids are rejected by the builder. Collection-item file fields are singular; represent multiple files as multiple collection items.

Nested collections

A Collection<TItem> field is allowed on a CollectionItemSet, so a collection item can itself contain child collections of the same or a different item schema (e.g. AssetGroups → Sections → Assets). Each nesting level is an ordinary Fields.Collection<TItem> property with its own MinimumItems / MaximumItems / AllowReordering, and the parent form still renders the whole tree through the same collection editor.

Moving items between collections

When a collection item schema appears as the item type of more than one Fields.Collection<TItem> field in the same record (typically at different levels of a nested hierarchy), the collection editor's selection toolbar shows a Move button next to Delete. Moving:

  • Lists every other collection field in the record whose item schema matches the source collection's, labeled with its ancestor items' titles (Group A › Section 1), so the destination is always unambiguous even when the containing branch is collapsed or not currently open in the editor.
  • Refuses destinations that would create a cycle (a collection nested inside the item(s) being moved) and destinations already at MaximumItems capacity.
  • Preserves each item's id — a move changes an item's parent collection, not its identity, and file blobs already attached to the item are carried over rather than re-uploaded or deleted. The generated Cosmos save path indexes every incoming and existing item by id across the whole record for exactly this reason, instead of only diffing each collection against its own previous sibling list.
  • Reaches the parent form action handler the same way any other item change does: request.CollectionItemContext.ChangeType is CollectionItemChangeType.Moved for the moved item(s).

A Boolean inside an collection item schema can limit how many items may be selected. The form keeps the newly selected item and clears older selections that exceed the limit, while server-side validation rejects invalid API payloads:

public Fields.Boolean IsCoverImage { get; set; } = new()
{
	DefaultValue = false,
	MaximumTrueItems = 1
};
Fields.File

File upload backed by Azure Blob Storage. Specialize it for images, videos, PDFs, or selected extensions through FileOptions instead of using a separate image field type.

public Fields.File Logo { get; set; } = new()
{
    FileOptions = new()
    {
        AcceptedContentTypes = ["image/*"],
        AcceptedExtensions = [".jpg", ".jpeg", ".png", ".webp"],
        MaximumFileSize = 10 * 1024 * 1024
    }
};

Direct browser uploads are placed in a private uploads container and promoted to the field's configured final storage path only when the record is saved. The server never proxies successfully direct-uploaded file bytes. Unattached blobs are removed by the server's cleanup worker after UploadsRetentionHours (24 hours by default). If UploadsContainerName is omitted, CDM uses {BaseName}-uploads (see BaseName).

Fields.TextEditor

Rich text field. Every toolbar feature is enabled by default; restrict what the field can do with the fluent configuration or by setting Toolbar directly. Toggles are per-feature, so "media" (image + video) and "color" (text color + highlight) can each be turned off as a group, or item by item.

public Fields.TextEditor Description { get; set; } = new(config => config
    .NoColor()    // disables text color + highlight color
    .NoMedia());  // disables image + video insertion

public Fields.TextEditor Summary { get; set; } = new(config => config
    .Images(false)     // no images
    .Videos()           // videos still allowed
    .CodeView(false));  // no HTML source view
Fields.Custom

Embeds a developer-supplied Razor component inside the form. Data is pushed from the Form Action Handler via UpdateCustomData.

public Fields.Custom AreasMap { get; set; } = new() { ComponentKey = "RegionMap" };

Register the component in your Blazor project (Program.cs, before the app builds) with AddCustomFieldComponent<TComponent>(key). It is safe to call once per component type; every call shares the same singleton ICustomFieldComponentRegistry:

builder.Services.AddCustomFieldComponent<RegionMapField>("RegionMap");

The component inherits CustomFormFieldBase and receives a Context (CustomFormFieldContext) on every render:

@* RegionMapField.razor *@
@inherits CustomFormFieldBase

<div>@Context.Data?.GetRawText()</div>

@code {
	private MyMapData? Data => GetData<MyMapData>();

	private Task OnPointSelected(double lat, double lng) =>
		ChangeValueAsync(nameof(Region.Areas), new { Lat = lat, Lng = lng });
}
Member Description
Context.Data The last JsonElement pushed via result.UpdateCustomData(field, data); GetData<T>() deserializes it
ChangeValueAsync(schemaName, value) Sets another field's value; counts as a user edit
SetBaselineAsync(schemaName, value) Sets what the form considers the field's starting value, without counting as an edit
RequestActionAsync(invoker) Re-invokes the form action handler (OnInput by default)

AddCustomFieldComponent must be registered on every host that renders the form — server and WebAssembly both need their own call, since each keeps its own DI container and therefore its own registry instance.

Fields.OneToManySubgrid

Used internally by the form renderer for one-to-many sub-grids. Not declared on the entity class - added automatically via the [OneToMany] attribute or FormConfig.Relationships.


5.4 Field Base Properties

These properties are available on every field type.

Property Type Default Description
SchemaName string (property name) Internal identifier; auto-set from the C# property name
Label string (auto from property name) Display label; auto-derived from PascalCase name
Description string? null Help text shown beneath the field
Required RequiredField? None None / Required (shows *) / Recommended (shows +)
IsVisible bool true Initial visibility on the form
Editable bool true Whether the field can be edited
IsPrimary bool false One field per entity should be primary; drives default search and record title
DefaultValue object? null Pre-populated value for new records
FullWidth bool false Spans the full form width on every form this field appears in. For per-form overrides use FormFieldConfig.ColSpan = 0 instead.
IsUnique bool false Enforces uniqueness at save time
IsSequential bool false Auto-incremented integer (uses CDM Sequence service)
MultipleValue bool false Allow multiple values (Address, Predefined, EntityReference, OnDemand)
LockingMode LockingModeField? Disabled Controls field-level locking behavior
IsLocked bool false Starts locked when true
FilesDirectoryPrefix string? null Final storage path prefix for File fields
Permissions List<FieldPermissionRule> [] Field-level security rules (section 8)
IsSearchable bool IsPrimary Participates in keyword search
SearchBoost double 1.0 Ranking weight during search
IsFilterable bool false Available in WHERE filters
IsSortable bool false Available in ORDER BY
IsDefaultSort bool false Used when no sort is provided
DefaultSortDirection SearchSortDirection Ascending Default sort direction
IsFacetable bool false Available as an aggregation facet
IncludeInGlobalSearch bool false Included in cross-entity search snippets
IsGlobalSearchTitle bool false Used as the title of the global search hit
VectorDimensions int? null Azure AI Search vector field size
VectorProfile string? "default" Azure AI Search vector profile name

5.5 Field & Class Attributes

Attributes provide a declarative alternative to property initializers. They are read automatically by EntitySet.GetEntity().

Class-level attributes
// Custom singular/plural display names (defaults to class name + "s")
[Entity("Product", "Products")]

// Opt entity out of cross-entity (global) search
[GlobalSearch(false)]

// Enable drag-to-reorder in views; adds a hidden DisplayOrder field automatically
[AllowReordering]
[AllowReordering("SortOrder")]   // custom order field schema name

The same behavior can be enabled when registering the entity in the fluent builder:

builder.Entity<Product>(allowReordering: true);

// Or use a custom persisted order field name:
builder.Entity<Category>()
    .AllowReordering("SortOrder");

This adds a hidden sequential whole-number field, makes it the default sort for every view, and enables the grid's drag-to-reorder controls.

Property-level search / filter / sort attributes
// Searchable - included in keyword search
[Searchable]
[Searchable(Boost = 2.0, IncludeInGlobalSearch = true)]
public Fields.SingleLine Name { get; set; } = new() { IsPrimary = true };

// Filterable - can be used in WHERE conditions
[Filterable]
public Fields.Predefined Status { get; set; } = new("StatusOptionSet");

// Sortable - can be used in ORDER BY
[Sortable]
[Sortable(IsDefault = true, DefaultDirection = SearchSortDirection.Ascending)]
public Fields.Date CreatedOn { get; set; } = new();

// Facetable - available as an aggregation facet in the UI
[Facetable]
public Fields.Predefined Category { get; set; } = new("CategoryOptionSet");

// Global search field - contributed to cross-entity search snippets
[GlobalSearchField]
[GlobalSearchField(IsTitle = true)]   // used as the hit title
public Fields.SingleLine Title { get; set; } = new() { IsPrimary = true };

// Vector field (Azure AI Search)
[VectorField(1536)]
[VectorField(1536, Profile = "my-vector-profile")]
public Fields.SingleLine EmbeddingVector { get; set; } = new() { IsVisible = false };
Relationship attributes (section 5.8)
// Parent side - "Contact has many Deals"
[OneToMany("Contact")]           // "Contact" = name of the ManyToOne property on the child
[OneToMany("Contact", "Deals")]  // optional label override
public Deal[] Deals { get; set; } = [];

// Child side - "Deal belongs to a Contact"
[ManyToOne]
[ManyToOne("ContactId")]         // optional key override
[ManyToOne(label: "Contact")]
public Fields.EntityReference Contact { get; set; } = new("Contact");

5.6 Forms

A form describes which fields appear when a user opens a record. Override Forms in your EntitySet to define one or more named forms.

protected override FormConfig[] Forms =>
[
	new FormConfig
	{
		SchemaName = "General",       // used to reference this form by name
		Name       = "General",       // display name (defaults to SchemaName)

		// Grid column count: 1, 2 (default), or 3.
		// Collapses to 2 columns on tablet and 1 column on mobile automatically.
		Columns = 2,

		// Top-level fields (placed before any section)
		Fields = [ nameof(Status) ],

		// One-to-many sub-grids (placed before sections)
		Relationships = [ "ContactDeals" ],

		Sections =
		[
			new FormSectionConfig("Personal Info")
			{
				// Implicit string -> FormFieldConfig conversion
				Fields =
				[
					nameof(FirstName),
					nameof(LastName),
					nameof(PhoneNumber),
				],
			},

			// Collapsed by default
			new FormSectionConfig("Additional", IsCollapsedByDefault: true)
			{
				Fields = [ nameof(Notes), nameof(Tags) ],
			},

			// Hidden by default - shown by form action / business rule
			new FormSectionConfig("SellerInfo")
			{
				IsVisibleByDefault = false,
				Fields =
				[
					nameof(SellerAddress),
					new FormFieldConfig { SchemaName = nameof(AskingPrice), IsVisibleByDefault = false },
				],
				Relationships = [ "ContactProperties" ],
			},
		],
	},

	// Second named form for a different role
	new FormConfig
	{
		SchemaName = "ReadOnlyForm",
		Sections =
		[
			new FormSectionConfig("Summary")
			{
				Fields = [ nameof(Name), nameof(Status) ],
			},
		],
	},
];
FormConfig properties
Property Type Default Description
SchemaName string "General" Identifier used to reference this form (e.g. for permissions)
Name string? SchemaName Display name shown in the UI
Columns int 2 Grid column count: 1, 2, or 3. Auto-collapses to 2 on tablet, 1 on mobile
Fields FormFieldConfig[] [] Top-level fields placed before any section
Relationships string[] [] One-to-many sub-grid schema names placed before sections
Sections FormSectionConfig[] [] Named sections that group fields
Field layout — FormFieldConfig

FormFieldConfig controls how an individual field is placed in the form grid. All fields in both top-level Fields and section Fields accept this type; plain nameof(...) strings convert implicitly.

Every field's width and height are assigned automatically at build time based on its type — you only set ColSpan / RowSpan when you want to override that default.

// Simple — just the field name (implicit conversion from string).
// The field's width and height are chosen automatically from its type.
nameof(FirstName)

// Explicit — override the auto-assigned layout
new FormFieldConfig
{
	SchemaName        = nameof(Notes),
	IsVisibleByDefault = true,   // false = hidden until shown by action handler
	ColSpan           = 0,       // 0 = full width (all columns)
	RowSpan           = 4,       // 4 standard field rows tall
}

FormFieldConfig properties:

Property Type Default Description
SchemaName string (required) Field schema name (use nameof(...))
IsVisibleByDefault bool true false = hidden until revealed by a form action. A required field that is hidden does not block the form from saving.
ColSpan int? auto (by type) Column span override. 0 = full width (all columns). 1/2/3 = explicit span (clamped to the form's column count). Leave unset to use the type default.
RowSpan int? auto (by type) Height override, measured in standard field rows. Leave unset to use the type default.
Auto-assigned defaults by field type

When you don't set ColSpan / RowSpan, the builder stamps these defaults onto the field at build time (so the sizing lives in code, not CSS). ColSpan 0 = full width; RowSpan is a count of standard field rows.

Field type ColSpan RowSpan
Single line, number, money, boolean, date, dropdown, entity reference 1 1
File 1 2
Multi-line text 1 3
Multi-value fields (chips) 1 2
Address full width 3
Schedule full width 3
Text editor (rich text) full width 5
One-to-many / many-to-many sub-grid full width 3
Custom component full width 3

ColSpan = 0 vs Field.FullWidth: setting ColSpan = 0 on a FormFieldConfig makes the field span all columns in that specific form. Field.FullWidth = true (on the field definition itself) makes the field full-width on every form it appears in. Both emit the same _fullWidth CSS class and can be used together or independently.

FormSectionConfig constructor:

new FormSectionConfig(
	name: "Section Display Name",
	schemaName: "SectionSchemaName",  // optional; defaults to name without spaces
	IsCollapsedByDefault: false        // optional; default false
)
{
	IsVisibleByDefault = true,         // false = hidden until shown by action handler
	Fields        = [ ... ],           // FormFieldConfig[] or implicit string[]
	Relationships = [ "RelName" ],     // one-to-many sub-grid schema names
}
Hiding required fields

When a field is hidden (via IsVisibleByDefault = false or a HideField action), CDM automatically skips its required-field validation. The form can be saved without providing a value for that field, even if the field is marked Required. The validation check resumes as soon as the field is made visible again.

Responsive column behaviour
Screen Columns
Desktop (> 1023 px) As configured (1, 2, or 3)
Tablet (641–1023 px) Max 2 (a 3-column form becomes 2)
Mobile (≤ 640 px) Always 1

Fields with ColSpan = 0 (full-width) or Field.FullWidth = true always span all columns at every breakpoint.


5.7 Views

A view describes the columns shown in the record list grid. Override Views in your EntitySet.

protected override ViewConfig[] Views =>
[
	new ViewConfig
	{
		SchemaName = "General",
		Name       = "All Contacts",
		Fields =
		[
			nameof(Name),
			nameof(PhoneNumber),
			nameof(Status),
			nameof(CreatedOn),
		],

		// Optional row-level action buttons
		RowButtons =
		[
			new RowButtonModel
			{
				SchemaName              = "MarkAnswered",
				Text                    = "Answer",
				Tooltip                 = "Mark as Answered",
				// Conditionally visible based on a field value
				VisibleFieldSchemaName  = nameof(Status),
				VisibleWhenFieldValueIn = ["ToBeContacted", "NotAnswered"]
			},
			new RowButtonModel
			{
				SchemaName              = "MarkNoAnswer",
				Text                    = "No Answer",
				VisibleFieldSchemaName  = nameof(Status),
				VisibleWhenFieldValueIn = ["ToBeContacted", "Answered"]
			}
		],
	},
];

RowButtonModel properties:

Property Description
SchemaName Identifier passed to the action handler's Run method
Text Button label
Tooltip Hover tooltip
VisibleFieldSchemaName Field whose value controls visibility
VisibleWhenFieldValueIn Array of option keys; button shows when field matches any

5.8 Relationships (One-to-Many)

Declare one-to-many relationships using [OneToMany] on the parent entity and [ManyToOne] on the child entity property.

// Parent - Property entity
[Entity("Property", "Properties")]
public class Property : EntitySet
{
	public Fields.SingleLine Title { get; set; } = new() { IsPrimary = true };

	// Declares that Property has many Media records
	[OneToMany("Property")]           // "Property" = name of the ManyToOne property on Media
	public Media[] Media { get; set; } = [];
}

// Child - Media entity
[Entity("Media", "Media Items")]
public class Media : EntitySet
{
	public Fields.SingleLine FileName { get; set; } = new() { IsPrimary = true };
	public Fields.File       File     { get; set; } = new();

	// Back-reference to parent
	[ManyToOne]
	public Fields.EntityReference Property { get; set; } = new("Property");
}

To include the sub-grid on a form, reference the relationship schema name (auto-generated as "{ToSchemaName}{FromSchemaName}"):

new FormSectionConfig("Media & Marketing")
{
	Fields        = [ nameof(CoverImage) ],
	Relationships = [ $"Property{nameof(Media)}" ],   // "PropertyMedia"
},

For relationships configured through the fluent builder, prefer the strongly typed overloads. They derive entity, field, and relationship schema names from the selected properties, and the same selectors can add the sub-grid to a form:

builder
	.HasOneToManyRelationship<Property, Media>(
		property => property.Media,
		media => media.Property)
	.IsRequired()
	.HasLabels("Media items", "Property");

builder.GetEntity(nameof(Property))
	.GetForm("General")
	.HasOneToMany<Property, Media>(
		property => property.Media,
		media => media.Property);

All relationship entry points share one case-insensitive canonical definition per schema name. Repeated compatible declarations are merged, while conflicting definitions fail early with the relationship name and conflicting metadata in the exception.


5.9 Option Sets

Option sets provide the choices for Fields.Predefined. Define them as classes inheriting BaseOptionSet.

using CDM.Builder.OptionSets;
using AngryMonkey.Cloud.CDM.Models;

[OptionSet(name: "Communication Status")]   // optional; defaults to class name
public class CommunicationStatus : BaseOptionSet
{
	public override OptionSetOption[] Options =>
	[
		new() { Key = "ToBeContacted", Value = "To Be Contacted" },
		new() { Key = "Answered",      Value = "Answered" },
		new() { Key = "NotAnswered",   Value = "Not Answered" },
		new() { Key = "CallReceived",  Value = "Call Received" },
	];
}

Reference the option set in a field using its schema name (class name by default, or the value from [OptionSet(schemaName: "...")]):

public Fields.Predefined CommunicationStatus { get; set; } = new("CommunicationStatus")
{
	DefaultValue = "ToBeContacted"
};

Register all option sets with the builder:

builder.OptionSet<CommunicationStatus>();
builder.OptionSet<LeadStatus>();
builder.OptionSet<PropertyTypes>();
CDMEnum - strongly-typed option sets

For strongly-typed enum-like option sets, derive from CDMEnum:

[JsonConverter(typeof(CDMEnumConverter<PropertyStatus>))]
public class PropertyStatus(string key, string title) : CDMEnum(key, title)
{
	public static readonly PropertyStatus Incomplete = new("Incomplete", "Incomplete");
	public static readonly PropertyStatus Active     = new("Active",     "Active");
	public static readonly PropertyStatus Sold       = new("Sold",       "Sold");
	public static readonly PropertyStatus Rented     = new("Rented",     "Rented");
}

5.10 Dashboard

The dashboard shows a configurable set of entity views as navigation items.

builder.HasDashboard()
	.HasDashboardView<Contact>()               // uses entity's default view
	.HasDashboardView<Property>()
	.HasDashboardView<Deal>()
	.HasDashboardView<Region>()
	// Named view overload:
	.HasDashboardView("Property", "ActiveProperties");

5.11 Security in Builder

Security can be configured inline on each entity/form/view, or centralized in a RoleBasedSecurityConfig class.

Inline - on builder.Entity<T>()
builder.Entity<Contact>()
	// Entity-level: which CRUD operations the role may perform
	.HasPermission("Admin",        EntityPermissions.All)
	.HasPermission("CallCenter",   EntityPermissions.Read | EntityPermissions.Update)
	.HasPermission("Telemarketer", EntityPermissions.Read,
				   readScope: AccessScope.Global, writeScope: AccessScope.User)

	// Form-level: which role may open and/or edit this form
	.GetForm()
		.HasPermission("CallCenter",   canRead: true, canEdit: true)
		.HasPermission("Telemarketer", canRead: true, canEdit: false)
	.Entity

	// View-level: which role may see this view
	.GetView()
		.HasPermission("CallCenter")
		.HasPermission("Telemarketer")
	.Entity

	// Named form / view
	.GetForm("BrokerForm")
		.HasPermission("Broker")
	.Entity
	.GetForm("MediaForm")
		.HasPermission("Media");
Centralized RoleBasedSecurityConfig
public class MySecurityConfig : RoleBasedSecurityConfig
{
	public override void Configure()
	{
		Entity("Contact")
			.HasPermission("Admin",      EntityPermissions.All)
			.HasPermission("CallCenter", EntityPermissions.Read | EntityPermissions.Update)
			.GetForm()
				.HasPermission("CallCenter", canRead: true, canEdit: true)
			.Entity
			.GetView()
				.HasPermission("CallCenter");

		Entity("Property")
			.HasPermission("Broker", EntityPermissions.All,
						   readScope: AccessScope.Global, writeScope: AccessScope.User)
			.GetForm("BrokerForm")
				.HasPermission("Broker")
			.Entity
			.GetView()
				.HasPermission("Broker");
	}
}

// Register in builder
builder.HasSecurity<MySecurityConfig>();
Field-level permissions via builder
builder.Entity<Contact>()
	.GetField("SalaryField")
		.HasPermission("Admin",  FieldPermissions.All)
		.HasPermission("Broker", FieldPermissions.Read);

Or via attribute:

// In the EntitySet class - using fluent builder approach in CDMBuilderField
// field.HasPermission(roleCode, FieldPermissions.Read | FieldPermissions.Edit)

5.12 Builder Options

await builder.GenerateAndReplaceFiles(new CDMBuilderOptions
{
	// Create brand-new project scaffolding (use once on new projects)
	CreateMainProjects = false,

	// Path to the local CDM source (for development builds using project references)
	LocalReferenceBasePath = @"..\..\..\CDM",

	// NuGet package version written into generated .csproj files
	PackageVersion = "7.0.0",

	// Target framework written into generated .csproj files
	TargetFramework = "net10.0",

	// Subdirectory relative to the builder project's parent
	BaseDirectory = null,

	// Overwrite existing non-generated files
	OverwriteFiles = false,

	// Skip re-creating already-existing project files
	SkipExistingProjects = true,

	// Create missing directories automatically
	CreateDirectoryStructure = true,

	// Throw on generation errors (false = log and continue)
	ThrowOnError = true,

	// Custom domain class name (defaults to "{SchemaName}Domain")
	CustomDomain = null,

	// BuilderOnly | FullProject | ExceptBuilder
	GenerationMode = CDMProjectGenerationMode.ExceptBuilder,
});

5.13 Running the Builder

# From the solution root
dotnet run --project MyApp.Portal.Builder

Run the builder whenever you:

  • Add or remove an entity
  • Add, remove, or rename a field
  • Change a form or view definition
  • Add or modify an option set
  • Change relationship declarations
  • Update security rules
  • Upgrade CDM to a version that changes a generated contract property type

A stale generated contract shows up at runtime as a deserialization error naming the offending property, for example Failed to deserialize RecordJson to type Brand. The JSON value could not be converted to System.String. Path: $.LogoFileName. Re-run the builder.

The builder only rewrites files whose content has changed and deletes obsolete files from auto-generated/ folders.

Before changing any files, the builder validates the complete configuration and displays a short preflight summary containing only the output location and the source-code/project-generation decisions. If validation fails, every detected error is shown together; press Enter to close the builder immediately. If validation succeeds, review any existing-project overwrite or skip warnings, then press Enter to proceed or Escape to close immediately without changing files.


6. Custom Domain - Overriding Entity Methods

The builder generates a DefaultXxxDomain base class that wires all entities to their auto-generated methods. Override specific entities to inject custom business logic.

// Domain.cs (hand-written file - NOT in auto-generated/)
using AngryMonkey.Cloud.CDM;

namespace MyApp.Portal.DataContract;

public class MyAppDomain(MyAppEngine engine, CloudGeographyClient cloudGeography)
	: DefaultMyAppDomain(engine)
{
	// Override only the entities you need to customize
	public override IRecordMethods<Contact>  Contact  => new ContactMethods(this, cloudGeography);
	public override IRecordMethods<Property> Property => new PropertyMethods(this);
	public override IRecordMethods<Deal>     Deal     => new DealMethods(this);
}

Custom methods class

// ContactMethods.cs (hand-written file in Engine project)
using AngryMonkey.Cloud.CDM;
using AngryMonkey.Cloud.CDM.Common;

public class ContactMethods(MyAppDomain domain, CloudGeographyClient geography)
	: DefaultContactMethods(domain)     // generated base provides default Save/Get/Delete/...
{
	// Override Save to add business logic
	public override async Task<Contact> Save(Contact record, RecordOptions.Save options)
	{
		// Compute derived field before saving
		record.Name = $"{record.FirstName} {record.LastName}".Trim();

		// Validate uniqueness
		var existing = await GetMany(new RecordOptions.GetMany
		{
			Filters = [new() { FieldSchemaName = nameof(Contact.PhoneNumber), Value = record.PhoneNumber }]
		});

		if (existing.Records.Any(r => r.ID != record.ID))
			throw new InvalidOperationException("A contact with this phone number already exists.");

		return await base.Save(record, options);
	}

	// Override Delete to cascade
	public override async Task Delete(Guid id, RecordOptions.Delete? options = null)
	{
		// Delete related deals first
		var deals = await domain.Deal.GetMany(new RecordOptions.GetMany
		{
			Filters = [new() { FieldSchemaName = "Contact", Value = id }]
		});

		foreach (var deal in deals.Records)
			await domain.Deal.Delete(deal.ID);

		await base.Delete(id, options);
	}

	// Handle row-button actions from the view grid
	public override async Task<EngineResponseBase> Run(string action, object[] args)
	{
		if (action == "MarkAnswered")
		{
			var id      = (Guid)args[0];
			var contact = await Get(id);
			contact.CommunicationStatus = "Answered";
			await Save(contact, new RecordOptions.Save { IsNew = false });
			return new EngineResponseBase { Success = true };
		}

		return await base.Run(action, args);
	}
}

Registering the form action handler on the engine

Override GetActionHandler in a hand-written partial class of the engine:

// Actions.cs (hand-written file in Engine project)
public partial class MyAppEngine
{
	public override FormActionHandler? GetActionHandler(string entitySchemaName)
		=> entitySchemaName switch
		{
			nameof(Property) => new FormActionHandler
			{
				EntitySchemaName = nameof(Property),
				Action           = PropertyAction.Handle
			},
			nameof(Contact) => new FormActionHandler
			{
				EntitySchemaName = nameof(Contact),
				Action           = ContactAction.Handle
			},
			_ => null
		};
}

6.1 Overriding Collection Item Methods

A collection item has no storage of its own, so it gets no CRUD API. It does get an overridable methods class, wired into the domain next to the entities:

public class MyAppDomain(MyAppEngine engine) : DefaultMyAppDomain(engine)
{
	public override IRecordMethods<Brand> Brand => new BrandMethods(this);

	// Note the different interface: collection items are not records.
	public override ICollectionItemMethods<GuidelineSection> GuidelineSection
		=> new GuidelineSectionMethods(this);
}

Two hooks are available, both driven by the owning record's save:

Member When it runs
Save(item, options) Before the owning record is persisted, once per item on the incoming record. Throwing aborts the whole save.
Delete(item, options) Before the owning record is persisted, once per item that was on the stored record and is gone from the incoming one.
ActionHandler On every change in the item's editor popup — the same handler shape entities use.

CollectionItemOptions carries the context an item needs in place of a record id:

public class GuidelineSectionMethods(DefaultMyAppDomain domain)
	: DefaultGuidelineSectionMethods(domain)
{
	public override Task<GuidelineSection> Save(
		GuidelineSection section,
		CollectionItemOptions.Save options)
	{
		section.Title = Require(section.Title, "Section title");

		// options.IsNew            - the item is not on the stored record
		// options.RootRecordId     - id of the stored record it belongs to
		// options.Root<Brand>()    - the incoming root record, pending changes applied
		// options.OwnerAs<T>()     - the immediate owner (root record, or the
		//                            containing item when the collection is nested)
		// options.Path             - collection items between root and this item
		Validate(options.Root<Brand>()?.AssetGroups, section.HeaderTextColor?.Key);

		return base.Save(section, options);
	}
}

Reading the collection from options.Root<T>() rather than from storage matters: sibling items and other fields on the record may be changing in the same save.

Delete is only invoked when a methods class actually overrides it — otherwise the engine skips the read of the stored record entirely.

Item changes are audit-logged individually under the item's own schema name and id, with the owning record identified in the entry details. They are written only after the record itself has been persisted.

Action handlers in the item popup

The item editor popup dispatches through the same UpdateFormModel entry point as a record form, so the whole of FormActionResult applies inside the popup — showing and hiding fields and sections, replacing option lists, changing labels and requirements.

The request differs in two ways from a record form's:

  • request.Output holds the item's own values, and its EntitySchemaName is the collection-item schema name.
  • request.ParentOutput holds the owning record form's values, so a handler can reach the record the item is being edited under — including its unsaved changes.
public override FormActionHandler? ActionHandler => new()
{
	EntitySchemaName = nameof(GuidelineSection),
	Action = request =>
	{
		FormActionResult result = new();

		SectionKinds? kind = request.Output.GetValue<SectionKinds>(
			nameof(GuidelineSection.SectionKind));

		if (kind?.Key == SectionKinds.Header.Key)
			result.ShowSection("Headersection");
		else
			result.HideSection("Headersection");

		// Options sourced from a collection on the parent record.
		IReadOnlyList<AssetsGroup> groups = request.ParentOutput
			?.GetCollectionItems<AssetsGroup>(nameof(Brand.AssetGroups)) ?? [];

		result.ChangeOptions(nameof(GuidelineSection.HeaderTextColor), ToOptions(groups));

		return Task.FromResult(result);
	}
};

request.CollectionItemContext identifies which item is being edited. On the popup's OnInit its ChangeType is Added for a new item and FieldChanged for an existing one, so a handler can seed defaults for new items with ChangeValue.

What the popup renders

The popup renders the collection item's configured form through the same component the record form uses, so the two look and behave alike: the form's sections in their configured order with their collapse and visibility defaults, the form's column count, the same field components, and the same validation presentation. Saving an item forces validation on every field and expands any collapsed section holding an error.

Field-level permissions apply inside the popup. A collection item with no HasPermission rules inherits whatever the owning collection field granted; once any rule is declared, a role without a matching rule gets no access. Field rules on the item's own fields narrow that further. A field the user cannot read is not rendered; one they can read but not edit renders read-only.


7. Form Action Handler

The form action handler is a server-side function the form calls on two events:

Invoker When called
OnInit When the form first loads (new or existing record)
OnInput When the user changes any field value

The handler receives the current form state (FormOutput) and returns a list of FormChange instructions that the UI applies immediately - without a page reload.

Handler function

public static class PropertyAction
{
	public static async Task<FormActionResult> Handle(FormActionRequest request)
	{
		FormActionResult result = new();
		FormOutput       form   = request.Output;

		//  On Init 
		if (request.Invoker == FormActionInvokers.OnInit)
		{
			// Pre-populate fields for new records
			if (form.IsNew)
				result.ChangeValue(nameof(Property.PropertyStatus), "Incomplete");

			// Lock the reference number once set
			if (form.HasValue(nameof(Property.ReferenceNumber)))
				result.DisableField(nameof(Property.ReferenceNumber));
		}

		//  Read typed values 
		string?  listingType = form.GetValue<string>(nameof(Property.Propertylistingtypes));
		string?  category    = form.GetValue<string>(nameof(Property.PropertyCategory));
		decimal? sellPrice   = form.GetValue<decimal?>(nameof(Property.SellAskingPrice));
		Guid?    brokerId    = form.GetValue<Guid?>(nameof(Property.Broker));

		//  Show/hide fields 
		if (listingType == "Sale")
		{
			result.ShowField(nameof(Property.SellAskingPrice));
			result.HideField(nameof(Property.RentAskingPrice));
		}
		else if (listingType == "Rent")
		{
			result.HideField(nameof(Property.SellAskingPrice));
			result.ShowField(nameof(Property.RentAskingPrice));
		}

		//  Show/hide sections 
		if (category == "Land")
			result.HideSection("Details");
		else
			result.ShowSection("Details");

		//  Collapse a section 
		result.CollapseSection("AdditionalDetails");

		//  Change a label dynamically 
		result.ChangeLabel(nameof(Property.AreaSize), "Land Area (m2)");

		//  Push options for an OnDemand field 
		var brokerOptions = await GetBrokerOptionsFromDb();
		result.ChangeOptions(nameof(Property.Broker), brokerOptions);

		//  Change requirement at runtime 
		result.ChangeRequirement(nameof(Property.FloorLevel), RequiredField.Required);

		//  Push data to a custom component (Fields.Custom) 
		result.UpdateCustomData(nameof(Property.AreasMap),
			new { Zoom = 14, Center = "33.888,35.495" });

		//  Trigger a redirect after save 
		result.WithAction(new FormActions.Redirect("/properties"));

		//  Open a confirmation dialog 
		result.WithAction(new FormActions.Dialog(
			Title:   "Confirm Delete",
			Message: "Are you sure you want to delete this record?",
			Buttons:
			[
				new FormDialogButton { Text = "Yes", Action = new FormActions.CloseDialog() },
				new FormDialogButton { Text = "No",  Action = null }
			]
		));

		return result;
	}
}

FormOutput - reading values

FormOutput form = request.Output;

bool   isNew   = form.IsNew;
Guid?  id      = form.RecordId;
Guid?  userId  = form.LoggedInUserId;
string entity  = form.EntitySchemaName;
bool   locked  = form.IsLocked;

// Strongly-typed value retrieval
string?   name    = form.GetValue<string>(nameof(Contact.Name));
int?      rooms   = form.GetValue<int?>(nameof(Property.BedroomNumber));
decimal?  price   = form.GetValue<decimal?>(nameof(Property.SellAskingPrice));
bool?     active  = form.GetValue<bool?>(nameof(Contact.IsActive));
Guid?     owner   = form.GetValue<Guid?>(nameof(Property.Broker));
DateTime? closing = form.GetValue<DateTime?>(nameof(Property.ClosingDate));

// Check presence
bool hasBroker = form.HasValue(nameof(Property.Broker));

FormActionResult - all available changes

Method Target Description
ChangeValue(field, value) Field Set a field's value
ChangeAddress(field, address) Address field Set an Address field
ChangeOptions(field, options) OnDemand / Predefined Replace the option list
ChangeLabel(field, label) Field Change the display label
HideField(field) Field Hide a field
ShowField(field) Field Show a hidden field
DisableField(field) Field Make read-only
EnableField(field) Field Re-enable a disabled field
ChangeRequirement(field, req) Field Change required state at runtime
CollapseSection(section) Section Collapse a form section
ExpandSection(section) Section Expand a collapsed section
HideSection(section) Section Hide a section
ShowSection(section) Section Show a hidden section
UpdateCustomData(field, data) Custom field Push JSON data to a Razor component
WithAction(FormActions) Form Navigate, show dialog, or close dialog

FormActions types:

  • new FormActions.Redirect(url) - navigate to a URL
  • new FormActions.CloseDialog() - close a dialog opened by another action
  • new FormActions.Dialog(title, buttons, message?) - open a modal dialog

8. Security & Permissions

Preconfigured roles

settings.Security.IsRoleBased = true;
settings.Security.PreconfiguredRoles.Add(new() { Code = "Broker",       Name = "Broker" });
settings.Security.PreconfiguredRoles.Add(new() { Code = "Telemarketer", Name = "Telemarketer" });
settings.Security.PreconfiguredRoles.Add(new() { Code = "CallCenter",   Name = "Call Center" });
settings.Security.PreconfiguredRoles.Add(new() { Code = "Media",        Name = "Media" });

EntityPermissions flags

Flag Description
Read View records
Create Create new records
Update Edit existing records
Delete Delete records
Append Associate other records to this entity (parent side)
AppendTo Associate this record to other records (child side)
Share Share records with other users
Assign Assign records to another user/team
All All of the above

FieldPermissions flags

Flag Description
Read View field value
Edit Edit field value
All Read + Edit

AccessScope - data scoping

Value Description
None No access
User Only records owned by / created by the logged-in user
Unit Records belonging to the user's business unit
Parental User's unit + child units in the hierarchy
Global All records (organisation-wide)

Applying scopes

// Same scope for read and write
.HasPermission("Broker", EntityPermissions.All, AccessScope.User)

// Independent read/write scopes
.HasPermission("Broker",
	EntityPermissions.Read | EntityPermissions.Update,
	readScope:  AccessScope.Global,   // can see all records
	writeScope: AccessScope.User)     // can only edit their own

Vertical security

Set settings.Security.IsVertical = true to enable org-hierarchy-based access control (organization business group business unit).


CDM supports three search providers, selectable at engine configuration time.

9.1 Cosmos search (default)

Enabled automatically when WithData(database, storage) runs. Performs CONTAINS-style keyword queries on all IsSearchable fields. No extra configuration required.

// Via connection strings
engine.WithAzureAISearch(
	endpoint:        "https://my-search.search.windows.net",
	apiKey:          "<admin-key>",
	indexPrefix:     "myapp-",          // optional; prefixes each entity index name
	globalIndexName: "myapp-global"     // optional; name of the cross-entity index
);

// Or via settings object
engine.WithAzureAISearch(new AzureAISearchSettings
{
	Endpoint   = "https://my-search.search.windows.net",
	ApiKey     = "<admin-key>",
	IndexPrefix = "myapp-"
});

Mark fields for indexing using attributes or fluent builder:

// Attributes on EntitySet properties
[Searchable(Boost = 2.0)]
[Sortable(IsDefault = true)]
[Filterable]
[Facetable]
[GlobalSearchField(IsTitle = true)]
public Fields.SingleLine Title { get; set; } = new() { IsPrimary = true };

[VectorField(1536)]
public Fields.SingleLine TitleVector { get; set; } = new() { IsVisible = false };

Or via fluent builder API:

builder.Entity<Property>()
	.GetField(nameof(Property.Title))
		.IsSearchable(boost: 2.0)
		.IsSortable(isDefault: true)
		.IsFilterable()
		.IsFacetable()
		.IsGlobalSearchTitle()
	.Entity
	.GetField(nameof(Property.TitleVector))
		.AsVector(dimensions: 1536, profile: "default");

9.3 Custom search provider

public class MySearchProvider : ISearchProvider
{
	public Task<SearchResult<T>> SearchAsync<T>(SearchRequest request, ...) { ... }
	public Task<GlobalSearchResult> SearchAllAsync(SearchRequest request, ...) { ... }
}

// Register
engine.WithSearch(new MySearchProvider());

10. Settings Module

The Settings module adds a dedicated portal section (accessible at /settings) for managing system configuration data such as security roles, geographic data, and custom lookup tables.

builder.AddCDMServer<MyEngine, MyCosmos, MyDomain>(settings, config =>
{
	config.Settings = s => s
		// Built-in: role-based access control management
		.HasRoleBasedAccessSettings(engine.Security.DataStorage)

		// Built-in: country/subdivision/city address data management
		.HasAddressesSettings(new MyAddressOperations())

		// Custom settings area via the fluent builder
		.HasArea("Currencies", "Currencies")
			.HasEntity("Currency", "Currency", "Currencies")
				.HasField("Code",   new Fields.SingleLine { Required = RequiredField.Required, IsPrimary = true })
				.HasField("Symbol", new Fields.SingleLine())
				.HasForm()
					.HasFormField("Code")
					.HasFormField("Symbol")
					.Entity
				.HasView()
					.HasViewField("Code",   100)
					.HasViewField("Symbol",  80)
					.Entity
				.HasMethods(new CurrencyMethods())
				.Area
		.Settings;
});

Custom area definition (preferred pattern)

Encapsulate an area's structure in a CDMSettingsAreaDefinition:

public class CurrencySettingsDefinition : CDMSettingsAreaDefinition
{
	public override void Define(CDMSettingsBuilder builder)
	{
		builder
			.HasArea("Currencies", "Currencies")
				.HasEntity("Currency", "Currency", "Currencies")
					.HasField("Code",   new Fields.SingleLine { IsPrimary = true })
					.HasField("Name",   new Fields.SingleLine())
					.HasForm()
						.HasFormField("Code")
						.HasFormField("Name")
						.Entity
					.HasView()
						.HasViewField("Code", 100)
						.Entity
					.HasMethods(new CurrencyMethods())
					.Area;
	}
}

// Register
config.Settings = s => s
	.Define(new CurrencySettingsDefinition())
	.Define(new AnotherDefinition());

11. Engine Advanced Features

Audit Log

// Settings
settings.AuditLog = new() { Enabled = true, TableName = "logs" };

When enabled, CDM automatically records every Save and Delete operation with user ID, timestamp, entity type, record ID, and changed values in Azure Table Storage.

Tracking (draft-change log for publishing)

Tracking is the Azure Table Storage-backed log of pending draft changes that backs the [Publishable] workflow below — not page-view analytics. It is enabled automatically whenever the module has at least one publishable entity; WithTracking only needs to be called explicitly to point it at different storage or to force it on/off independent of that default:

engine.WithTracking(storageConnectionString, tableName: "tracking");

// Equivalent via settings — CDMTrackingSettings
settings.Tracking = new()
{
	Enabled          = null,   // null = follow Module.HasPublishing (default)
	ConnectionString = null,   // falls back to Storage.ConnectionString
	TableName        = null    // falls back to "{BaseName}changes"
};

Publishing as drafts ([Publishable])

Mark an entity [Publishable] (or builder.Entity<T>().Publishable()) to make every save on that entity a draft: the record is stored normally and immediately readable, but the change is also appended to the Tracking log as pending. The portal header shows a publish button with the pending-change count; publishing raises the module's publish event and then clears the tracked entries for the ones that were included.

[Entity("Article", "Articles")]
[Publishable]
public class Article : EntitySet { /* ... */ }

// Or via the fluent builder
builder.Entity<Article>().Publishable();

Because the build declares the publish event as a partial method, a project with at least one [Publishable] entity fails to compile until a hand-written partial class of the generated engine implements it:

// Publish.cs (hand-written file in the Engine project)
public partial class MyAppEngine
{
	private partial async Task OnPublishAsync(PublishEventArgs args)
	{
		// args.Changes  - pending TrackingRecord entries, oldest first
		// args.PartitionKey - tenant partition being published, if tenant-scoped

		foreach (TrackingRecord change in args.Changes)
			await PublishToLive(change.EntitySchemaName, change.RecordId);
	}
}

If OnPublishAsync throws, the tracked changes stay pending and the user can retry — publishing is all-or-nothing per invocation.

This is a different mechanism from the dual-database WithPublishing below: [Publishable] tracks which records have unpublished edits in a single database, while WithPublishing copies finished records into a separate publishing database. A project can use either independently, or both together (e.g. draft-track changes, then have the publish event copy the record into the publishing database).

Sequence - auto-increment fields

Use IsSequential = true on a Fields.WholeNumber to get a per-entity auto-incrementing integer backed by Azure Table Storage:

public Fields.WholeNumber ReferenceNumber { get; set; } = new()
{
	Editable     = false,
	IsSequential = true
};

Sequences are stored in the table named by CDMStorageSettings.SequenceTableName.

Entity reference delete protection

Deleting a record is blocked while another record still references it through an EntityReference field — on by default, no configuration needed. The check runs once per delete request, in the engine itself (not in generated code), by scanning every EntityReference field across the whole module for one that targets the entity being deleted, and querying for a match the same way a picker's option set already does.

DELETE Company/{id}
  -> 400 {
       "CDMError": "cdm-error",
       "IsHandled": true,
       "Message": "This record cannot be deleted: it is still referenced by
                   2 Brands through 'Parent'.",
       "TotalRecordCount": 2,
       "Records": [
         { "RecordId": "…", "EntitySchemaName": "Brand", "Name": "MelonCut",
           "Reason": "Brand · Parent" },
         { "RecordId": "…", "EntitySchemaName": "Brand", "Name": "LazyBanana",
           "Reason": "Brand · Parent" }
       ]
     }

Every referring field is queried, not just the first one that matches, so the answer is complete: an admin does not unlink one field only to hit the same wall again. The first five referencing records travel back as records rather than as names inside a sentence, so the form dialog lists them as links straight to the records that have to be changed, and counts the rest ("and 7 more").

The response is a handled error: the dialog shows the message and the links, and offers no technical details — nothing failed.

The form asks before it asks the user. Clicking Delete first posts to Methods/CanDeleteRecord, which runs the same permission gate and the same guard without touching any data; only when it comes back clean does the "Are you sure?" popup open. Being asked to confirm something that is then refused reads as a broken promise. DeleteRecord still runs the guard itself — a reference added between the check and the confirmation is caught, and API callers that skip the check are guarded all the same.

Cascading deletes are unaffected: deleting a parent record still deletes its one-to-many/many-to-many children as usual (this check only guards the top-level delete request, not the cascade steps it triggers), so removing a whole subtree in one action still works.

Opt a specific field out where an orphaned reference is harmless — e.g. a soft link that's read defensively rather than counted on:

public Fields.EntityReference RelatedArticle { get; set; } = new("Article", config =>
	config.AllowDeleteWhenReferenced());

There is no entity-wide switch — the check is declared per field, at the same place the reference itself is declared, so turning it off is always an explicit, visible decision next to the field it affects.

Handled and unhandled errors

A failed request says whether the server refused on purpose. The form error dialog reads that flag and shows technical details only when something actually broke — an anticipated rejection is already the whole story, and a stack trace next to it only suggests a bug where there is none.

// Anticipated: the message is written for the user, and may name records.
throw new CDMUserException("Pick a company before adding a brand.");

// Anticipated: field validation, raised anywhere in the engine.
throw new ValidationException("'Owner' must reference a Company.");

Endpoints classify what escapes them with CDMErrorPayload.FromException(exception): a CDMUserException or ValidationException anywhere in the chain is handled; anything else is a defect and travels with its exception chain (types and messages — stack traces stay on the server).

catch (Exception e)
{
	CDMErrorPayload payload = CDMErrorPayload.FromException(e);

	if (!payload.IsHandled)
		Console.WriteLine($"[MethodsController] DeleteRecord failed: {e}");

	return BadRequest(payload);
}

On the client every failed call throws a CDMRequestException — an HttpRequestException, so existing handlers are unaffected — carrying IsHandled, Records, and ServerDetails. An endpoint that still returns a plain BadRequest("…") keeps working: any 4xx with a message is read as handled, while a 5xx or an empty body is not.

Failure Dialog shows Details button
Delete blocked by references message + links to the first five referring records no
Validation rejected a value message no
BadRequest("Cannot delete a system role.") message no
An exception escaped an endpoint message yes — server exception chain, then the client one

Publishing (dual-database)

engine.WithPublishing(publishingDatabase, publishingStorage);

Useful for staging-to-production publish workflows. Normal reads come from Database; published records are written to PublishingDatabase.

Azure Maps integration

settings.Maps = new()
{
	AzureSubscriptionKey = "<key>",
	ValidateAddresses    = true     // geocode and validate addresses on save
};

When ValidateAddresses = true, saving a record with an Address field triggers server-side geocoding via Azure Maps to validate and enrich the address with coordinates.


12. Complete Sample - BluSky Portal

The following is the complete, real usage from the BluSky real-estate CRM built on CDM.

Program.cs

using CDM;
using AngryMonkey.Cloud.CDM;
using BluSky.Portal;
using BluSky.Portal.DataContract;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

builder.Services.AddHttpClient();

CDMSettings settings = new()
{
	Title    = "BluSky Portal",
	Theme    = new() { AccentColor = "#051c51" },
	AuditLog = new() { Enabled = true }
};

settings.Security.IsRoleBased = true;
settings.Security.PreconfiguredRoles.Add(new() { Code = "Broker",       Name = "Broker" });
settings.Security.PreconfiguredRoles.Add(new() { Code = "Telemarketer", Name = "Telemarketer" });
settings.Security.PreconfiguredRoles.Add(new() { Code = "CallCenter",   Name = "Call Center" });
settings.Security.PreconfiguredRoles.Add(new() { Code = "Media",        Name = "Media" });

builder.Configuration.GetSection("CDM").Bind(settings);

builder.Services.AddCors(options =>
	options.AddPolicy("PortalCors", policy =>
		policy.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod()));

builder.AddCDMServer<BluSkyPortalEngine, BluSkyPortalCosmos, BluSkyPortalDomain>(settings);

var app = builder.Build();

app.UseCors("PortalCors");
app.UseCDM<BluSkyPortalDomain>();

await app.RunAsync();

Builder Program.cs

using AngryMonkey.Cloud.CDM;
using BluSky.Portal.Builder.Entities;

var builder = new CDMBuilder("BluSky.Portal");
builder.Module.DefaultCountryCode = "LB";

builder.Entity<Contact>()
	.GetView()
		.HasPermission("CallCenter")
		.HasPermission("Telemarketer")
		.HasPermission("Broker")
	.Entity
	.GetForm()
		.HasPermission("CallCenter")
		.HasPermission("Telemarketer")
		.HasPermission("Broker");

builder.Entity<Property>()
	.GetForm("BrokerForm")
		.HasPermission("Broker")
	.Entity
	.GetForm("MediaForm")
		.HasPermission("Media")
	.Entity
	.GetView()
		.HasPermission("Broker")
		.HasPermission("Media");

builder.Entity<Media>()
	.GetForm()
		.HasPermission("Media")
		.HasPermission("Broker")
	.Entity
	.GetView()
		.HasPermission("Media")
		.HasPermission("Broker");

builder.Entity<Deal>();
builder.Entity<Document>();
builder.Entity<Region>();

builder.HasDashboard()
	.HasDashboardView<Contact>()
	.HasDashboardView<Property>()
	.HasDashboardView<Deal>()
	.HasDashboardView<Region>();

await builder.GenerateAndReplaceFiles(new CDMBuilderOptions
{
	CreateMainProjects = false
});

Entity example - Contact (from BluSky)

[Entity("Contact", "Contacts")]
public class Contact : EntitySet
{
	public Fields.SingleLine FirstName { get; set; } = new() { Required = RequiredField.Required, IsVisible = false };
	public Fields.SingleLine LastName  { get; set; } = new() { Required = RequiredField.Required, IsVisible = false };
	public Fields.SingleLine Name      { get; set; } = new() { IsVisible = false, IsPrimary = true };

	public Fields.SingleLine PhoneNumber { get; set; } = new(Fields.SingleLineValidation.PhoneNumber)
	{
		Required = RequiredField.Required
	};

	public Fields.Predefined CommunicationStatus { get; set; } = new("CommunicationStatus")
	{
		Required     = RequiredField.Required,
		IsVisible    = false,
		DefaultValue = "ToBeContacted"
	};

	public Fields.Predefined ClientRole { get; set; } = new("ClientRoles")
	{
		Required     = RequiredField.Required,
		DefaultValue = "None",
		IsVisible    = false
	};

	public Fields.OnDemand Broker { get; set; } = new()
	{
		Behavior      = FieldBehavior.DropDown,
		MultipleValue = true,
		IsVisible     = false
	};

	public Fields.Money BuyerBudgetRent { get; set; } = new() { Label = "Budget (Rent)" };
	public Fields.Money BuyerBudgetBuy  { get; set; } = new() { Label = "Budget (Buy)" };

	public Fields.Address BuyerCities { get; set; } = new()
	{
		Label           = "Cities",
		MultipleValue   = true,
		Country         = new() { IsRequired = true, IsEnabled = false, IsVisible = false, DefaultValue = "LB" },
		Subdivision     = new() { IsRequired = true },
		SubdivisionChild = new() { IsRequired = true },
		City            = new() { IsRequired = true }
	};

	public Fields.Predefined BuyerPropertyTypes { get; set; } = new("PreferredPropertyTypes")
	{
		Label         = "Property Types",
		MultipleValue = true
	};

	protected override FormConfig[] Forms =>
	[
		new FormConfig
		{
			Sections =
			[
				new FormSectionConfig("Personal Info")
				{
					Fields =
					[
						nameof(PhoneNumber),
						nameof(FirstName),
						nameof(LastName),
						nameof(Channel),
						nameof(CommunicationStatus),
						nameof(LeadStatus),
						nameof(ClientRole),
						nameof(Broker),
					],
				},
				new FormSectionConfig("Seller Basic Info")
				{
					IsVisibleByDefault = false,
					Fields =
					[
						nameof(ListingType),
						nameof(SellerAddress),
						nameof(SellerAskingPriceSale),
						nameof(SellerAskingPriceRent),
					],
				},
				new FormSectionConfig("Buyer Basic Info")
				{
					IsVisibleByDefault = false,
					Fields =
					[
						nameof(RequirementType),
						nameof(BuyerCities),
						nameof(BuyerPropertyTypes),
						nameof(BuyerBudgetBuy),
						nameof(BuyerBudgetRent),
					],
				},
			],
		},
	];

	protected override ViewConfig[] Views =>
	[
		new ViewConfig
		{
			Fields =
			[
				nameof(Name),
				nameof(PhoneNumber),
				nameof(ClientRole),
				nameof(CommunicationStatus),
			],
			RowButtons =
			[
				new RowButtonModel
				{
					SchemaName              = "MarkAnswer",
					Text                    = "Answer",
					Tooltip                 = "Mark as Answered",
					VisibleFieldSchemaName  = nameof(CommunicationStatus),
					VisibleWhenFieldValueIn = ["ToBeContacted", "NotAnswered"]
				},
				new RowButtonModel
				{
					SchemaName              = "MarkNoAnswer",
					Text                    = "No Answer",
					VisibleFieldSchemaName  = nameof(CommunicationStatus),
					VisibleWhenFieldValueIn = ["ToBeContacted", "Answered"]
				},
			],
		},
	];
}

Entity example - Region (with Fields.Custom)

[Entity("Region", "Regions")]
public class Region : EntitySet
{
	public Fields.SingleLine Name   { get; set; } = new() { Required = RequiredField.Required, IsPrimary = true };
	public Fields.OnDemand   Broker { get; set; } = new() { Required = RequiredField.Required, MultipleValue = false };

	public Fields.Address Areas { get; set; } = new()
	{
		MultipleValue    = true,
		Country          = new() { IsRequired = true, IsEnabled = false, IsVisible = false, DefaultValue = "LB" },
		Subdivision      = new() { IsRequired = true },
		SubdivisionChild = new() { IsRequired = true },
		City             = new() { IsRequired = false }
	};

	// Custom Razor component embedded in the form
	public Fields.Custom AreasMap { get; set; } = new() { ComponentKey = "RegionMap" };

	protected override FormConfig[] Forms =>
	[
		new FormConfig
		{
			Sections =
			[
				new FormSectionConfig("General")
				{
					Fields = [ nameof(Name), nameof(Broker), nameof(Areas) ],
				},
				new FormSectionConfig("Map")
				{
					Fields = [ nameof(AreasMap) ],
				},
			],
		},
	];

	protected override ViewConfig[] Views =>
	[
		new ViewConfig { Fields = [ nameof(Name), nameof(Broker) ] },
	];
}

Entity example - Property (multiple forms + [OneToMany])

[Entity("Property", "Properties")]
public class Property : EntitySet
{
	public Fields.SingleLine      Title           { get; set; } = new() { Editable = false, IsPrimary = true };
	public Fields.SingleLine      ReferenceNumber { get; set; } = new() { Editable = false };
	public Fields.EntityReference CoverImage      { get; set; } = new(nameof(Media));
	public Fields.EntityReference CoverVideo      { get; set; } = new(nameof(Media));

	public Fields.Address Address { get; set; } = new()
	{
		Required    = RequiredField.Required,
		Country     = new() { IsRequired = true, IsEnabled = false, IsVisible = false, DefaultValue = "LB" },
		Subdivision = new() { Codes = ["BA", "JL", "AS"], IsRequired = true },
		SubdivisionChild = new() { IsRequired = true },
		City        = new() { IsRequired = true },
		Map         = new() { IsRequired = false }
	};

	public Fields.SingleLine OwnerPhoneNumber { get; set; } = new(Fields.SingleLineValidation.PhoneNumber)
	{
		Label    = "Phone Number",
		Required = RequiredField.Required,
		Behavior = FieldBehavior.Autocomplete
	};

	public Fields.Predefined Propertylistingtypes { get; set; } = new("PropertyListingType")
	{
		Required     = RequiredField.Required,
		Label        = "Property Listing Type",
		DefaultValue = "Sale"
	};

	public Fields.Predefined PropertyStatus { get; set; } = new("PropertyStatuses")
	{
		DefaultValue = "Incomplete",
		Behavior     = FieldBehavior.Buttons
	};

	public Fields.Money SellAskingPrice { get; set; } = new() { Description = "Public Price" };
	public Fields.Money RentAskingPrice { get; set; } = new() { Description = "Public Price" };

	public Fields.OnDemand Broker { get; set; } = new()
	{
		Required      = RequiredField.Required,
		MultipleValue = false
	};

	public Fields.DecimalNumber AreaSize { get; set; } = new()
	{
		Label       = "Area Size (m2)",
		Required    = RequiredField.Required,
		Description = "Enter the Area Size m2"
	};

	public Fields.WholeNumber BedroomNumber     { get; set; } = new();
	public Fields.WholeNumber BathroomsNumber   { get; set; } = new();
	public Fields.WholeNumber ParkingSpotsNumber { get; set; } = new();
	public Fields.MultipleLines Notes           { get; set; } = new() { FullWidth = true };

	// One-to-many relationship - Property has many Media items
	[OneToMany("Property")]
	public Media[] Media { get; set; } = [];

	protected override FormConfig[] Forms =>
	[
		// Form for Broker role
		new FormConfig
		{
			SchemaName = "BrokerForm",
			Sections =
			[
				new FormSectionConfig("Owner")
				{
					Fields = [ nameof(OwnerPhoneNumber), nameof(OwnerFirstName), nameof(OwnerLastName) ],
				},
				new FormSectionConfig("Basic")
				{
					Fields =
					[
						nameof(Title), nameof(ReferenceNumber),
						nameof(PropertyStatus), nameof(Address),
						nameof(Broker)
					],
				},
				new FormSectionConfig("Classification")
				{
					Fields =
					[
						nameof(Propertylistingtypes), nameof(PropertyType),
						nameof(SellAskingPrice),      nameof(RentAskingPrice),
					],
				},
				new FormSectionConfig("Areas")
				{
					Fields = [ nameof(AreaSize), nameof(TerraceAreaSize), nameof(Notes) ],
				},
				new FormSectionConfig("Details")
				{
					Fields =
					[
						nameof(FloorLevel),      nameof(FurnishingStatus),
						nameof(BedroomNumber),   nameof(BathroomsNumber),
						nameof(ParkingSpotsNumber), nameof(Amenities),
					],
				},
				new FormSectionConfig("Media And Marketing")
				{
					Fields        = [ nameof(CoverImage), nameof(CoverVideo) ],
					Relationships = [ $"Property{nameof(Media)}" ],
				},
			],
		},

		// Simplified form for Media role
		new FormConfig
		{
			SchemaName = "MediaForm",
			Sections =
			[
				new FormSectionConfig("Basic")
				{
					Fields = [ nameof(Title), nameof(ReferenceNumber), nameof(PropertyStatus), nameof(Address) ],
				},
				new FormSectionConfig("Media And Marketing")
				{
					Fields        = [ nameof(CoverImage), nameof(CoverVideo) ],
					Relationships = [ $"Property{nameof(Media)}" ],
				},
			],
		},
	];

	protected override ViewConfig[] Views =>
	[
		new ViewConfig
		{
			Fields =
			[
				nameof(Title),
				nameof(OwnerFirstName),
				nameof(OwnerPhoneNumber),
				nameof(PropertyCategory),
				nameof(Propertylistingtypes),
			],
		},
	];
}

Custom Domain - Domain.cs (BluSky)

public class BluSkyPortalDomain(BluSkyPortalEngine engine, CloudGeographyClient cloudGeography)
	: DefaultBluSkyPortalDomain(engine)
{
	public override IRecordMethods<Contact>  Contact  => new ContactMethods(this, cloudGeography);
	public override IRecordMethods<Property> Property => new PropertyMethods(this);
	public override IRecordMethods<Deal>     Deal     => new DealMethods(this);
	public override IRecordMethods<Media>    Media    => new MediaMethods(this);
	public override IRecordMethods<Document> Document => new DocumentMethods(this);
	public override IRecordMethods<Region>   Region   => new RegionMethods(this);
}

13. Common Pitfalls

Editing files inside auto-generated/

All files carrying the // Auto-Generated header are overwritten on every builder run. Place custom logic in hand-written files that inherit from the generated base classes.

Missing UseCDM<TDomain>()

AddCDMServer registers services; UseCDM<TDomain>() mounts the middleware pipeline (routing, SignalR hub, static assets). Both are required.

Roles defined after AddCDMServer

Roles must be added to settings.Security.PreconfiguredRoles before AddCDMServer is called.

DefaultValue key mismatch

DefaultValue on a Fields.Predefined must match the Key of an OptionSetOption, not its display Value:

// OptionSetOption: new() { Key = "ToBeContacted", Value = "To Be Contacted" }
// WRONG:  DefaultValue = "To Be Contacted"
// CORRECT:
DefaultValue = "ToBeContacted"

Missing IsPrimary field

Every entity needs exactly one field with IsPrimary = true. This drives the record title in pickers and the default search column.

Relationship schema name mismatch

The relationship schema name in Relationships = [...] is auto-generated as "{ToSchemaName}{FromSchemaName}":

// [OneToMany("Property")] public Media[] Media { get; set; } = [];
// Relationship schema name = "Property" + "Media" = "PropertyMedia"
Relationships = [ "PropertyMedia" ]   // correct
Relationships = [ "Media" ]           // wrong - will silently render nothing

Stale form or view field names after a refactor

Fields = [...] entries that match no field on the entity are skipped silently. This bites hardest when a relationship is converted to a collection: nameof(Brand) still compiles once the Brand property is gone, because it resolves to the type name.

The builder reports each one in its preflight summary:

Warnings:
  ! GuidelineSection form 'General' section 'Section' references 'Brand', which is not a field on this entity. It was skipped.

Converting a relationship to a collection item

An entity that becomes a CollectionItemSet loses its storage, controllers, and record CRUD. Expect to change, in order:

  1. builder.Entity<T>() registration — remove it; collection items are discovered from the parent's Fields.Collection<T> field.
  2. [ManyToOne] / [OneToMany] properties on both sides — replaced by the collection field.
  3. Relationships = [...] in the parent form — becomes Fields = [nameof(TheCollection)].
  4. Stale nameof(Parent) entries in the item's own form and view configs (see above).
  5. The domain override — IRecordMethods<T> becomes ICollectionItemMethods<T>.
  6. The methods class — RecordOptions.Save becomes CollectionItemOptions.Save, and parent lookups that went through storage read options.Root<TParent>() instead.

Running the builder while the portal is running

The builder deletes and recreates files inside auto-generated/ folders. Stop the portal process first to avoid file-lock errors.


14. External Client - CDM.ExternalClient

AngryMonkey.CDM.ExternalClient is a small, dependency-free HTTP client for calling a deployed CDM portal's API from outside the portal itself — a backend service, a script, a mobile app, or any process that is not a CDM Blazor host. Unlike the auto-generated MyApp.Portal.Client (used inside the portal's own Blazor components), it needs no generated per-entity code: one generic client works against any entity by schema name.

Configuring and creating a client

using CDM;

ExternalClientConfiguration configuration = new()
{
	BaseUrl      = "https://myapp-portal.example.com",
	ApiKey       = "<api-key>",       // sent as X-API-Key, if your host validates one
	BearerToken  = null,              // or sent as Authorization: Bearer <token>
	Timeout      = TimeSpan.FromSeconds(30),
	ThrowOnError = true               // false = failed calls return null instead of throwing
};

using ExternalClient client = new(configuration);
// Or supply your own HttpClient (e.g. from IHttpClientFactory):
// using ExternalClient client = new(httpClient, configuration);

BaseExternalClient<Contact> contacts = client.For<Contact>();
// Or override the entity's URL path segment explicitly:
// BaseExternalClient<Contact> contacts = client.For<Contact>("contacts");

client.For<T>() resolves the entity's URL path segment from T's Type property (falling back to the type name), lower-cased, and hits api/external/{path}/... endpoints on the host.

BaseExternalClient<T> operations

Method Description
SaveAsync(entity, isNew, fields?, userId?, ct) Create or update a record. fields limits which properties are written
GetAsync(id, fields?, userId?, ct) Fetch a single record by id
GetNewAsync(parentSchemaName?, parentId?, userId?, ct) Get a blank record pre-populated with defaults, optionally scoped to a one-to-many parent
GetManyAsync(parentSchemaName?, parentId?, page, count, fields?, userIds?, businessUnitIds?, recordsIds?, userId?, ct) Paged query, with optional owner/business-unit/id filtering
GetManyAsOptionSetAsync(parentSchemaName?, parentId?, userId?, ct) Records as id → display value pairs, for building a picker without loading full records
DeleteAsync(id, userId?, ct) Delete a record; returns whether it succeeded
FormModelAsync(formModel, userId?, ct) Runs the same form-model resolution the portal UI uses (metadata + current field state)
RunActionAsync(action, args, userId?, ct) Invokes a domain method's Run(action, args) override — the same entry point row-button actions use

Every call is async and accepts a CancellationToken. userId impersonates a user for permission evaluation when the host allows external callers to specify one; omit it to run as whatever identity the API key/bearer token maps to.

Other external clients

Client Purpose
GeographyExternalClient AddCityAsync(...) — add a city to the module's geography lookup table from outside the portal
LookupExternalClient Read/write named lookup categories: GetSuggestionsAsync, GetEntriesAsync, GetAllAsync, GetAllEntriesAsync, AddAsync
GlobalSearchExternalClient SearchAsync(SearchRequest, userId?, ct) — run the same cross-entity global search the portal UI uses
SecurityClient Read-only, periodically-refreshed cache of the security model: roles, role membership, and effective entity/field/form/view permissions, for a service that needs to make its own authorization decisions without round-tripping to the portal on every check

SecurityClient polls the portal on RefreshInterval (constructor parameter) and exposes synchronous lookups once InitializeAsync() has completed — GetEffectiveEntityPermissions(userId, entitySchemaName), GetEffectiveFieldPermissions(userId, entitySchemaName, fieldSchemaName), CanReadForm / CanEditForm / CanReadView, IsUserInRole, IsGlobalAdmin, and the full GetAccessMatrix(). Each of these external clients takes the same HttpClient + ExternalClientConfiguration constructor pair as ExternalClient.


Additional Resources


Last Updated: 2025 - Version 7.x

Native external data providers and CloudLogin

CDM entities can be backed by an external provider without treating the provider as a second CDM database. The generic runtime contract is IExternalDataProvider; CloudLogin is the first implementation. An integrated entity stores only a stable reference (ExternalProvider, ExternalEntityType, and ExternalEntityId) plus explicitly configured synchronized fields and CDM-owned metadata.

Data-source modes

ExternalDataSourceMode.External (“CloudLogin”) is a read-only view over the provider. Independent creation and deletion are rejected, the generated provider picker is required and read-only, and provider-owned fields are read-only.

ExternalDataSourceMode.ExternalAndCdm (“CloudLogin + CDM”) permits CDM-only records and optionally linked records. Lists combine persisted CDM records with currently available provider records. A linked provider record is de-duplicated; an unlinked provider record is synthesized as an unmanaged, read-only row and can be explicitly converted with CreateInCdm. Conversion never creates a CloudLogin entity and is idempotent for the external identifier.

Builder configuration

ExternalDataSources is the entry point, in the same shape as Fields: one branch per provider, one factory per entity type, and a typed builder for anything beyond the defaults.

Defaults — the entity declares nothing at all. Fields, the primary field, the form, and the view all come from the provider:

[Entity("Contact", "Contacts")]
public sealed class Contact : EntitySet
{
    protected override ExternalEntityConfiguration? ExternalDataSource =>
        ExternalDataSources.CloudLogin.User.Defaults(ExternalDataSourceMode.External);
}

Custom — the entity owns its fields and says which provider field each one follows:

protected override ExternalEntityConfiguration? ExternalDataSource =>
    ExternalDataSources.CloudLogin.Workspace.Custom()
        .Map(nameof(Name), CloudLoginWorkspaceField.Name)
        .Map(nameof(BillingEmail), CloudLoginWorkspaceField.BillingEmail)
        .Relate(nameof(Contacts), CloudLoginRelationship.Members, nameof(Contact));

DefaultsWith — the defaults, plus overrides for the ones to relabel or hide:

protected override ExternalEntityConfiguration? ExternalDataSource =>
    ExternalDataSources.CloudLogin.Workspace.DefaultsWith()
        .Map(CloudLoginDefaultFieldNames.Workspace.Name,
             CloudLoginWorkspaceField.Name,
             label: "Business Name");

A provider CDM does not know — field names are strings here, because only that provider knows them:

protected override ExternalEntityConfiguration? ExternalDataSource =>
    ExternalDataSources.Custom("Stripe", "Customer")
        .Map(nameof(Email), "email");

Provider field names are enums, one per entity type (CloudLoginWorkspaceField, CloudLoginUserField, CloudLoginSubscriptionField), so mapping a User field onto a Workspace does not compile. CDM field names are passed as nameof(...), and generation fails if one matches no declared field rather than leaving a mapping that silently never syncs. CloudLoginSchema is the single source of truth for the whole vocabulary, and CloudLoginSchemaDriftTests pins it against what CloudLoginExternalDataProvider actually emits.

Values are translated with a typed overload rather than a raw dictionary:

.Map(nameof(Status), CloudLoginSubscriptionField.Status, values => values
    .Translate(CloudLoginSubscriptionStatus.Suspended, SubscriptionStatuses.Paused)
    .Translate(CloudLoginSubscriptionStatus.Expired, SubscriptionStatuses.Cancelled))

Created/modified timestamps follow the provider wherever it reports one — including in Custom mode, since that is a property of the entity type rather than of how its fields were configured. Opt out with .WithoutExternalTimestamps().

The builder adds the generic reference picker, LinkedOn, LinkedBy, LastSyncedOn, error metadata, an External Data form section, and SyncExternal / OpenExternal row actions. The runtime domain wraps normal generated methods with ExternalRecordMethods<TRecord> and a registered provider.

Ownership and synchronization

Each ExternalFieldMapping declares External, Cdm, or Synchronized ownership. External fields are pulled and rendered read-only. CDM fields remain local. Synchronized fields declare Pull, Push, or Bidirectional behavior; provider updates only occur during an explicit manual Sync and only when that provider supports updates. CloudLogin's current service contract is read-only, so unsupported writes fail explicitly rather than being silently attempted.

When configured, CreatedOnExternalFieldName and ModifiedOnExternalFieldName make record lifecycle timestamps follow the external entity. Linking never substitutes its own time for these values. LinkedOn and LinkedBy independently record when and by whom the relationship was created and remain stable across repeated synchronization.

Lists, forms, navigation, and on-demand data

The generated picker calls the provider for current entities and stores the stable external ID. Combined views contain CDM-only, linked, and unmanaged external rows. CreateInCdm turns an unmanaged row into a linked CDM record; OpenExternal returns a record-specific provider deep link; and SyncExternal refreshes only configured fields and updates synchronization metadata.

Provider-backed relationships and permissions are retrieved on demand with GetRelationshipsAsync and GetPermissionsAsync. CloudLogin Workspace membership is exposed as a generic relationship to User IDs, and permission identifiers remain strings owned by CloudLogin. Data not configured as a CDM field can be requested directly through IExternalDataProvider without persisting it.

Webhooks

Applications expose their own authenticated webhook endpoint and translate the versioned CloudLogin event into ExternalWebhookEvent. Pass it to ExternalRecordMethods<TRecord>.ProcessWebhookAsync with a durable IExternalEventReceiptStore implementation. Processing finds linked records only, fetches the authoritative provider entity, applies configured pull mappings, records errors, and never creates CDM records. Successful event IDs are duplicate-protected; failed receipts are retryable. Production hosts should persist receipts and validate the CloudLogin HMAC signature before calling the processor.

Manual Sync remains available for recovery and deterministic refresh even when webhooks are enabled.

Extending the integration

To add a CloudLogin entity type: add its contract/service operation to CloudLoginExternalDataProvider, add its field enum and default templates to CloudLoginSchema, and expose it on ExternalDataSources.CloudLogin. The drift tests will tell you immediately if the enum and the provider disagree. A new external system implements IExternalDataProvider; no Business, Contact, or Subscription-specific runtime is required.

15. Aspire Integration

AngryMonkey.CDM.Aspire.Hosting adds a CDM server to a .NET Aspire AppHost as an ordinary project resource, wired to whatever Cosmos, Storage, and CloudLogin resources the model already declares:

using AngryMonkey.Cloud.CDM.Aspire.Hosting;

var builder = DistributedApplication.CreateBuilder(args);

var cosmos = builder.AddAzureCosmosDB("cosmos");
var storage = builder.AddAzureStorage("storage");
var login = builder.AddCloudLogin<Projects.My_Login>("login");

var portal = builder.AddCDM<Projects.My_Portal>("portal", settings => settings.BaseName = "MyApp");

// The backend channel CDM's external data provider reads CloudLogin-owned records over. Its own
// call rather than a flag on WithReference: that key bypasses user identity, so it is granted only
// to servers that need it.
portal.WithReference(login).WithServiceAccess(login).WaitFor(login);
portal.WithReference(cosmos).WithReference(storage).WaitFor(cosmos).WaitFor(storage);

BaseName defaults to the resource name ("portal" above) and is not a label - it is what Cloud.CDM.Config's naming conventions (§3) derive every Cosmos database, container, and table name from, exactly as it does with no Aspire in the picture.

On the server side, AngryMonkey.CDM.Aspire is what a CDM server hosted under Aspire uses to bind CDMSettings from what the AppHost wired, instead of repeating Cosmos/Storage connection details in its own appsettings.json. The same configuration keys work with no AppHost anywhere in the picture - an appsettings.json, a user secret, an environment variable set by hand - so a CDM server stays runnable outside Aspire too.

CoconutSharp builds on both packages for environment-aware publishing (naming, identity, App Service targets per Dev/Staging/Production) without changing how CDM itself is configured - see its own README for the composed example (CloudLogin + CDM + a web frontend, deployed to more than one environment from one AppHost).

Product 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
8.0.1 63 8/27/2026
8.0.0 67 8/26/2026
7.3.5 93 8/15/2026
7.3.4 98 8/3/2026
7.3.3 102 8/2/2026
7.3.2 100 8/2/2026
7.3.1 105 8/2/2026
7.3.0 95 8/1/2026
7.2.1 106 7/25/2026
7.2.0 102 7/25/2026
7.1.8 100 7/22/2026
7.1.7 101 7/21/2026
7.1.6 102 7/21/2026
7.1.5 97 7/21/2026
7.1.4 98 7/20/2026
7.1.3 106 7/13/2026
7.1.2 96 7/13/2026
7.1.1 111 7/9/2026
7.1.0 108 7/9/2026
7.0.9 102 7/8/2026
Loading failed