Every piece of live data in MeshWeaver is accessed through a workspace reference — a typed lens over the underlying EntityStore. A reference describes what you want; the framework does the work of subscribing, reducing, serializing, and keeping values in sync. Custom reference types let you add new lenses with full write-back support.
Workspace reference reduction hierarchy (top) and bidirectional owner-subscriber sync (bottom).
Built-in Reference Types
The platform ships with six reference types that cover the most common access patterns:
| Reference | Reduces To | Purpose |
|---|---|---|
CollectionReference(name) |
InstanceCollection |
All entities in a named collection |
CollectionsReference(names) |
EntityStore |
A named subset of collections |
EntityReference(collection, id) |
object |
A single entity by collection + id |
InstanceReference(id) |
object |
A single entity by id |
JsonPointerReference(pointer) |
JsonElement |
A JSON path within a stream |
MeshNodeReference() |
MeshNode |
The hub's own MeshNode, with typed write-back |
Getting Streams
Local stream (own hub)
// Full EntityStore stream for a type
var stream = workspace.GetStream(typeof(MeshNode));
// Typed observable shorthand
var nodes = workspace.GetStream<MeshNode>();
Remote stream (another hub)
GetRemoteStream<TValue, TReference> is the generic cross-hub reducer subscription (layout areas, custom references, collections):
// Generic typed reference with write-back support
var stream = workspace.GetRemoteStream<UiControl, LayoutAreaReference>(
new Address(path), new LayoutAreaReference("Overview"));
🚨 For a MeshNode by path,
GetRemoteStream<MeshNode, …>THROWS — useworkspace.GetMeshNodeStream(path)(the sharedIMeshNodeStreamCachehandle, read +.Update(...)write-back on one stream).Workspace.ThrowIfMeshNoderefuses the public overloads forMeshNodebecause the single-node remote reduce does not converge; the raw reduce is reserved for framework plumbing via the internalGetRemoteStreamUnchecked. See CQRS and Data Access Patterns.
Registering Custom References
Custom workspace references give you a named, typed projection of data with proper serialization and optional write-back. Registration is a three-step pattern inside DataContext configuration.
Step 1 — Define the reference type
A reference type is a simple record that inherits WorkspaceReference<T>:
public record MeshNodeReference() : WorkspaceReference<MeshNode>;
Step 2 — Register the reducer
The reducer maps a parent stream into the target type. You register it on the ReduceManager via ForReducedStream:
config.AddData(data => data
.Configure(rm => rm
// Reducer: InstanceCollection → MeshNode
.ForReducedStream<InstanceCollection>(reduced => reduced
.AddWorkspaceReference<MeshNodeReference, MeshNode>(ReduceToMeshNode))
// PatchFunction: write-back from subscriber to owner
.ForReducedStream<MeshNode>(reduced => reduced
.AddPatchFunction(PatchMeshNode))
// Stream factory: resolves MeshNodeReference requests at runtime
.AddWorkspaceReferenceStream<MeshNode>(
(workspace, reference, configuration) =>
{
if (reference is not MeshNodeReference) return null;
var collectionStream = workspace.GetStream(
new CollectionReference(nameof(MeshNode)));
return (collectionStream as ISynchronizationStream<InstanceCollection>)
?.Reduce((WorkspaceReference<MeshNode>)reference, configuration);
})));
The reducer function maps ChangeItem<InstanceCollection> → ChangeItem<MeshNode>. For patch events it forwards the relevant EntityUpdate rather than re-reducing the whole collection:
private static ChangeItem<MeshNode> ReduceToMeshNode(
ChangeItem<InstanceCollection> current, MeshNodeReference reference, bool initial)
{
var node = current.Value?.Instances.Values.OfType<MeshNode>().FirstOrDefault();
if (initial || current.ChangeType != ChangeType.Patch)
return new(node, current.StreamId, current.Version);
// For patches, forward the relevant EntityUpdate
var change = current.Updates.FirstOrDefault();
if (change == null) return null!;
return new(change.Value as MeshNode, current.ChangedBy, current.StreamId,
ChangeType.Patch, current.Version, [change]);
}
The patch function deserializes a JsonElement back to the typed object when a subscriber writes a change. It must produce an EntityUpdate so the owning hub can apply the mutation correctly:
private static ChangeItem<MeshNode> PatchMeshNode(
ISynchronizationStream<MeshNode> stream, MeshNode current,
JsonElement updated, JsonPatch? patch, string changedBy)
{
var updatedNode = updated.Deserialize<MeshNode>(stream.Hub.JsonSerializerOptions);
return new(updatedNode!, changedBy, stream.StreamId, ChangeType.Patch,
stream.Hub.Version,
[new EntityUpdate(nameof(MeshNode), updatedNode?.Path, updatedNode)
{ OldValue = current }]);
}
Updating via Streams
GetMeshNodeStream(path).Update(...) (the canonical mutation API)
One call handles own, local-collection, and remote nodes — the handle auto-dispatches. It returns a cold observable; the write only runs on Subscribe:
// Own node (this hub):
workspace.GetMeshNodeStream().Update(node =>
node with { Content = updatedContent })
.Subscribe(_ => { }, ex => logger.LogWarning(ex, "update failed"));
// Any other node — same API; the write routes to the owning hub as a
// RFC 7396 JSON-merge patch via the process-wide IMeshNodeStreamCache:
workspace.GetMeshNodeStream(remotePath).Update(node =>
node with { Content = updatedContent })
.Subscribe(_ => { }, ex => logger.LogWarning(ex, "update failed"));
// Typed content update — unpacks and repacks Content for you
// (MeshNodeExtensions in MeshWeaver.Graph; delegates to the same handle):
workspace.UpdateMeshNode<MyContentType>(path,
(node, content) => node with
{
Content = content with { Title = "Updated" }
})
.Subscribe(_ => { }, ex => logger.LogWarning(ex, "update failed"));
Direct stream update
When you already hold an EntityStore stream (inside data-source plumbing), MeshNodeExtensions.UpdateMeshNode applies the change to the store directly:
stream.UpdateMeshNode(node =>
node with { Content = updatedContent }, nodePath);
The Reduce Chain
Data flows downward from EntityStore through successive reductions. Each level adds a finer-grained projection:
EntityStore (root store — all collections)
│
├── CollectionReference → InstanceCollection (one collection)
│ ├── EntityReference → object (single entity by collection + id)
│ ├── InstanceReference → object (single entity by id)
│ └── MeshNodeReference → MeshNode (typed MeshNode)
│
├── CollectionsReference → EntityStore (subset of collections)
│
└── JsonPointerReference → JsonElement (JSON path)
Each level can register a PatchFunction for write-back. Write-back travels the chain in reverse: typed changes are serialized to JSON, dispatched to the owner hub via PatchDataChangeRequest or DataChangeRequest, and applied using the registered PatchFunction.
🚨 An intermediate reduce is ReduceShared, never Reduce
Every level of that chain is a hub. WorkspaceStreams.CreateReducedStream constructs a
SynchronizationStream, which constructs a hosted sync/{id} sub-hub with its own Autofac lifetime
scope, TypeRegistry and JsonSerializerOptions — about 140 KB — and then registers the child for
disposal on its parent. So a reduce is neither free nor garbage-collectable: when the parent is a
data source's primary EntityStore stream, whose lifetime is the hub's, every call to Reduce mints
a hub that is reclaimed only when the whole hub dies.
That matters for the middle of a chain, because a middle stream has no owner. A stream factory
that reduces primary → InstanceCollection → MeshNode hands the last stream to its caller, who
disposes it on unsubscribe — but the InstanceCollection stream in the middle is nameless, nobody
disposes it, and building a fresh one per call leaks one hub per call. Uncached, that factory runs
once per inbound SubscribeRequest.
// ❌ leaks a sync/ hub per call — the intermediate is parented on a hub-lifetime stream
var collectionStream = primary?.Reduce<InstanceCollection>(new CollectionReference(nameof(MeshNode)));
// ✅ memoized on the parent; one intermediate per (parent, reference), for the parent's life
var collectionStream = primary?.ReduceShared<InstanceCollection>(new CollectionReference(nameof(MeshNode)));
return collectionStream?.Reduce((WorkspaceReference<MeshNode>)ownPathReference, configuration);
The rule is ownership, not depth:
| the stream is… | use | why |
|---|---|---|
| an intermediate nobody owns or disposes | ReduceShared |
one instance per (parent, reference), dies with the parent |
the stream you hand to a caller who will Dispose() it |
Reduce |
sharing would let one holder's teardown kill another's live stream |
caller-specific (a configuration with a client id / subscriber) |
Reduce |
it is not shareable by construction |
ReduceShared is deliberately opt-in rather than a change to Reduce: the Blazor LayoutAreaView
explicitly disposes the dialog and progress streams it reduces off its area stream, so making every
reduce shared would break the second view on the same area. The same split — memoize when there is no
caller-specific configuration — is what Workspace.GetStream does one layer up.
🚨 A cached child does NOT die with its parent, so caching one must check the PARENT's liveness, not just the child's.
CreateReducedStreambuilds the child'ssync/{id}sub-hub understream.Host— the data source's hub — so it is the parent's sibling, not its descendant, and it stays alive after the parent is disposed. A cache that only asks "is the child still alive?" therefore keeps serving a mirror of a dead source: reads bind to a stream that will never emit and never complete, and the reader gets neither an answer nor the NACKDataExtensions.HandleGetDataRequest's disposal arm owes it — it hangs for its whole budget.ReduceSharedbypasses the cache entirely once the parent is disposed, falling through to a plainReduceso the behaviour is exactly what it always was.SilentReadNackTestpins this.
Bidirectional Sync
When a subscriber calls stream.Update(fn) on a remote stream, the framework executes a six-step round trip to keep the owner authoritative:
stream.Update(fn)posts anUpdateStreamRequestto the local sync hub.- The sync hub executes the update function and calls
SetCurrent(). - The feedback subscription detects the change and converts it to
PatchDataChangeRequest(JSON element streams) orDataChangeRequest(typed streams). - The change is forwarded to the owner hub:
hub.Post(e, o => o.WithTarget(owner)). - The owner applies the change using its registered
PatchFunction. - The owner broadcasts the reconciled state to all subscribers.
A feedback predicate ensures only client-originated changes travel back — owner broadcasts are filtered out, preventing echo loops.