MeshWeaver models data mutations as reactive messages flowing through a typed workspace. Rather than imperative method calls, you describe what changed and the workspace propagates the delta to every subscriber automatically — no polling, no manual refresh.
Scope: this page covers typed-entity CRUD — instances (
TodoItem,Project, …) living in a workspace's EntityStore collections. It is not the API for mutating MeshNodes. A MeshNode's lifecycle goes throughCreateNodeRequest/DeleteNodeRequest/MoveNodeRequest, and a MeshNode's content is mutated exclusively viaworkspace.GetMeshNodeStream(path).Update(current => modified).Subscribe(...)— see Node Operations and CQRS — Queries vs. Content Access.
CRUD pipeline: every mutation passes through Access Control and Validation before reaching the Workspace, which persists the change and fans it out to all subscribers in real time.
Data Model
Every workspace maintains an EntityStore: a map of named collections, each holding a set of typed entity instances keyed by their ID.
EntityStore
├── Collections["TodoItems"] = InstanceCollection
│ ├── Instances["todo-1"] = TodoItem { Id: "todo-1", Title: "Task 1" }
│ └── Instances["todo-2"] = TodoItem { Id: "todo-2", Title: "Task 2" }
└── Collections["Projects"] = InstanceCollection
└── Instances["proj-1"] = Project { Id: "proj-1", Name: "Alpha" }
An InstanceCollection is a container for instances of a specific entity type, mapping IDs to objects.
Create
DataChangeRequest
The primary way to add new entities is to send a DataChangeRequest with the Creations payload:
var newTodo = new TodoItem
{
Id = Guid.NewGuid().ToString(),
Title = "Learn MeshWeaver",
Status = TodoStatus.Pending
};
var request = DataChangeRequest.Create([newTodo], changedBy: "user-123");
workspace.RequestChange(request, delivery);
Create Flow
Read
Everything is a live stream. The same primitive serves both "keep me updated" and "give me the current state" — there is no separate snapshot API to keep consistent.
Canonical node access — GetMeshNodeStream(path)
Any node — own, local, or remote — is read through workspace.GetMeshNodeStream(path). It is live and authoritative (served by the owning hub, no index lag), and the same handle accepts writes via .Update(...):
// Live subscription — emits the current node, then every subsequent change:
workspace.GetMeshNodeStream("acme/TodoLists/work")
.Where(node => node is not null)
.Subscribe(node =>
{
var list = node.ContentAs<TodoList>(hub.JsonSerializerOptions, logger);
// render / react
});
// One-shot read — same primitive, complete after the first emission:
workspace.GetMeshNodeStream(path)
.Where(node => node is not null)
.Take(1)
.Timeout(TimeSpan.FromSeconds(10))
.Subscribe(node => /* use the snapshot */);
Never read a known node through QueryAsync — queries are an eventually-consistent index for sets, stale right after a write. See CQRS — Queries vs. Content Access.
Reactive Subscription — typed collections
Subscribe to a typed collection as an IObservable that emits the full current set on every change:
workspace.GetObservable<TodoItem>()
.Subscribe(todos =>
{
Console.WriteLine($"Todos updated: {todos.Count}");
foreach (var todo in todos)
Console.WriteLine($" - {todo.Title}");
});
For a one-shot collection snapshot, compose on the same stream — workspace.GetObservable<TodoItem>().Take(1).Timeout(...) — instead of a request/response round-trip.
Reference Types
Choose the right reference type for your query pattern:
| Reference Type | Purpose | Example |
|---|---|---|
EntityReference |
Single entity by ID | new EntityReference("TodoItems", "todo-1") |
CollectionReference |
All entities in a collection | new CollectionReference("TodoItems") |
CollectionsReference |
Multiple collections at once | new CollectionsReference("TodoItems", "Projects") |
Unified Reference Paths
Path-based references give a uniform addressing scheme across entity and content resources.
The prefix is separated by a colon, and what follows it starts with the owning address —
{prefix}:{addressType}/{addressId}/…. A path with no recognised colon prefix falls through to
area, so a slash-separated "data/TodoItems" is parsed as an area reference, not data.
var allTodosRef = "data:app/my-app/TodoItems"; // entire collection
var todoRef = "data:app/my-app/TodoItems/todo-1"; // specific entity
var fileRef = "content:app/my-app/uploads/doc.pdf"; // file content
var areaRef = "area:app/my-app/Dashboard"; // layout area
The three recognised prefixes are data:, area: and content: (ParseUnifiedPath).
There is no schema: prefix.
Virtual Paths
Define computed data sources that combine or transform real collections:
.WithVirtualPath("TodoSummary", (workspace, entityId) =>
{
var todos = workspace.GetStream(typeof(TodoItem));
var users = workspace.GetStream(typeof(User));
return Observable.CombineLatest(todos, users, (t, u) =>
{
// Compute summary by joining data
return new TodoSummary { ... };
});
})
Virtual paths participate in real-time propagation just like ordinary collections.
Update
DataChangeRequest
Pass updated entity instances in the Updates payload. By default, changes are merged into the existing record:
var updatedTodo = existingTodo with
{
Title = "Updated Title",
Status = TodoStatus.InProgress
};
var request = DataChangeRequest.Update([updatedTodo], changedBy: "user-123");
workspace.RequestChange(request, delivery);
Update Options
Control whether a change merges or replaces the entire collection:
// Merge (default) — only the supplied instances change
var mergeRequest = DataChangeRequest.Update(
updates: [updatedTodo],
changedBy: "user-123",
options: new UpdateOptions { Snapshot = false }
);
// Snapshot — the entire collection is replaced by the supplied list
var snapshotRequest = DataChangeRequest.Update(
updates: allTodos,
options: new UpdateOptions { Snapshot = true }
);
Updating node content — GetMeshNodeStream(path).Update(...)
When the thing you are updating is a node's content (not an entity inside a collection), use the canonical mutation API — the same handle you read from:
workspace.GetMeshNodeStream("acme/TodoLists/work").Update(node =>
{
var list = node.ContentAs<TodoList>(hub.JsonSerializerOptions, logger);
if (node.Content is not null && list is null) return node; // never clobber unreadable content
list ??= new TodoList();
return node with { Content = list with { Title = "Updated Title" } };
})
.Subscribe(_ => { }, ex => logger.LogWarning(ex, "update failed"));
Update is cold — the write only happens on Subscribe. Cross-hub writes ship an RFC 7396 JSON-merge patch to the owning hub, so concurrent writers touching different fields both land.
Workspace Extension Methods
Convenience wrappers for the most common patterns:
// Single entity
workspace.Update(updatedTodo, delivery);
// Multiple entities
workspace.Update([todo1, todo2, todo3], delivery);
Delete
DataChangeRequest
Pass the full entity object (not just the ID) so the workspace can resolve and remove the correct instance:
var request = DataChangeRequest.Delete([todoToDelete], changedBy: "user-123");
workspace.RequestChange(request, delivery);
Workspace Extension Methods
workspace.Delete(todoToDelete, delivery);
workspace.Delete([todo1, todo2], delivery);
Deleting a node (as opposed to an entity in a collection) is a lifecycle operation: hub.Observe<DeleteNodeResponse>(new DeleteNodeRequest(path)).Subscribe(...) — see Node Operations.
Data Validation
Attach validators to enforce business rules before any change is applied. The workspace calls every registered validator and returns DataValidationResult.Invalid(...) to the caller if any rule is violated.
public class TodoValidator : IDataValidator
{
// Reactive, never Task<T> — this runs on the hub.
public IReadOnlyCollection<DataOperation> SupportedOperations { get; } =
[DataOperation.Create, DataOperation.Update];
public IObservable<DataValidationResult> Validate(DataValidationContext context)
{
if (context.Entity is TodoItem todo && string.IsNullOrEmpty(todo.Title))
return Observable.Return(
DataValidationResult.Invalid("Title is required"));
return Observable.Return(DataValidationResult.Valid());
}
}
Register validators in DI — the workspace resolves every registered IDataValidator:
services.AddScoped<IDataValidator, TodoValidator>();
Access Control
Restrict operations based on user context. Access restrictions run before validation, so unauthorized requests are rejected early.
Global restriction — apply a rule to all operations in a data source:
.AddData(data => data
.WithAccessRestriction(
(action, context, accessCtx) =>
{
if (action == AccessAction.Read)
return Observable.Return(true); // anyone can read
return Observable.Return(accessCtx.UserContext != null); // writes require login
},
"RequireAuthForWrites"
)
)
Type-specific restriction — limit access at the entity level:
.AddSource(src => src
.WithType<TodoItem>(type => type
.WithAccessRestriction((action, ctx, accessCtx) =>
{
var todo = ctx as TodoItem;
// Only the owner may modify their own todos
return Observable.Return(
todo?.OwnerId == accessCtx.UserContext?.ObjectId
);
}, "OwnerOnly")
)
)
Configuration Example
A complete data source setup showing multiple types, a virtual path, a validator, and an access rule:
.AddData(data => data
.AddSource(src => src
.WithType<TodoItem>(type => type
.WithKey(todo => todo.Id)
// Every WithInitialData overload takes an IObservable factory (or a fixed
// collection) — there is no Task/async overload. A real I/O leaf goes
// through an IIoPool, never Observable.FromAsync.
.WithInitialData(() => dbPool.Invoke(ct => LoadTodosFromDatabaseAsync(ct)))
)
.WithType<Project>(type => type
.WithKey(proj => proj.Id)
)
)
.WithVirtualPath("Dashboard", ComputeDashboard)
.WithAccessRestriction(RequireAuthentication, "Auth")
)
// Validators are registered in DI, not on the data configuration:
services.AddScoped<IDataValidator, TodoValidator>();
Real-Time Synchronization
Every CRUD operation automatically propagates to all current subscribers. Clients never need to re-query; the workspace pushes the updated collection as soon as the change is applied.
Quick Reference
Message Types
| Message | Purpose |
|---|---|
DataChangeRequest |
Create, update, or delete entities |
DataChangeResponse |
Outcome of a change operation |
GetDataRequest |
One-time data retrieval |
GetDataResponse |
Data retrieval result |
SubscribeRequest |
Subscribe to live data changes |
UpdateUnifiedReferenceRequest |
Create/update via path reference |
DeleteUnifiedReferenceRequest |
Delete via path reference |
Best Practices
- Use typed observables — prefer
GetObservable<T>()over raw streams for compile-time safety. - Check the response — inspect
DataChangeResponse.Status(Committed/Failed) before assuming success; the detail is in.Log. Note aWarningstatus still COMMITS. - Batch related changes — group inserts, updates, and deletes into a single
DataChangeRequestfor atomic delivery. - Register validators — enforce data integrity at the data layer rather than in each call site.
- Protect with access restrictions — declare who may read or write each type alongside the type configuration.
- Prefer subscriptions over polling — reactive streams keep UIs in sync with zero manual refresh logic.
Live Demo
The cell below builds a small in-memory summary table using the reference types described above, rendered directly in this page:
var rows = new[]
{
new { Type = "EntityReference", Purpose = "Single entity by ID", Example = "new EntityReference(\"TodoItems\", \"todo-1\")" },
new { Type = "CollectionReference", Purpose = "All entities in collection", Example = "new CollectionReference(\"TodoItems\")" },
new { Type = "CollectionsReference", Purpose = "Multiple collections at once", Example = "new CollectionsReference(\"TodoItems\", \"Projects\")" },
};
MeshWeaver.Layout.Controls.DataGrid(rows)
.WithColumn(new MeshWeaver.Layout.DataGrid.PropertyColumnControl<string>
{ Property = "type" }.WithTitle("Reference Type"))
.WithColumn(new MeshWeaver.Layout.DataGrid.PropertyColumnControl<string>
{ Property = "purpose" }.WithTitle("Purpose"))
.WithColumn(new MeshWeaver.Layout.DataGrid.PropertyColumnControl<string>
{ Property = "example" }.WithTitle("Example"))
See Also
- Query Syntax — Search and filter nodes
- Unified Path — Path-based data addressing
- Data Binding — Connect UI controls to data
- Editor Control — Generate forms from records