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 through CreateNodeRequest/DeleteNodeRequest/MoveNodeRequest, and a MeshNode's content is mutated exclusively via workspace.GetMeshNodeStream(path).Update(current => modified).Subscribe(...) — see Node Operations and CQRS — Queries vs. Content Access.

Client DataChangeRequest Access Control Validator Business Rules Workspace EntityStore Storage Persist check validate apply persist Subscriber A Subscriber B Subscriber C real-time fan-out

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

sequenceDiagram participant Client participant Workspace participant Validator participant DataSource participant Storage Client->>Workspace: DataChangeRequest.Create([entity]) Workspace->>Validator: Validate creation Validator-->>Workspace: Validation result Workspace->>DataSource: Add to InstanceCollection DataSource->>Storage: Persist changes Storage-->>DataSource: Confirm write Workspace-->>Client: DataChangeResponse Workspace->>Client: Notify subscribers (real-time)

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.

sequenceDiagram participant ClientA as Client A (Subscriber) participant Workspace participant ClientB as Client B (Editor) ClientA->>Workspace: Subscribe to TodoItems Workspace-->>ClientA: Initial data [todo-1, todo-2] ClientB->>Workspace: Create todo-3 Workspace-->>ClientA: Update: [todo-1, todo-2, todo-3] ClientB->>Workspace: Update todo-1 Workspace-->>ClientA: Update: [todo-1*, todo-2, todo-3] ClientB->>Workspace: Delete todo-2 Workspace-->>ClientA: Update: [todo-1*, todo-3]

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

  1. Use typed observables — prefer GetObservable<T>() over raw streams for compile-time safety.
  2. Check the response — inspect DataChangeResponse.Status (Committed / Failed) before assuming success; the detail is in .Log. Note a Warning status still COMMITS.
  3. Batch related changes — group inserts, updates, and deletes into a single DataChangeRequest for atomic delivery.
  4. Register validators — enforce data integrity at the data layer rather than in each call site.
  5. Protect with access restrictions — declare who may read or write each type alongside the type configuration.
  6. 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

Reconnecting…
The server was updated. Reloading the page to pick up the latest version.