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.

EntityStore CollectionReference → InstanceCollection CollectionsReference → EntityStore (subset) JsonPointerReference → JsonElement EntityReference → object (by id+coll) InstanceReference → object (by id) MeshNodeReference → MeshNode Subscriber Hub stream.Update(fn) → SetCurrent → PatchDataChangeRequest Owner Hub PatchFunction → broadcast reconciled state write sync

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 — use workspace.GetMeshNodeStream(path) (the shared IMeshNodeStreamCache handle, read + .Update(...) write-back on one stream). Workspace.ThrowIfMeshNode refuses the public overloads for MeshNode because the single-node remote reduce does not converge; the raw reduce is reserved for framework plumbing via the internal GetRemoteStreamUnchecked. 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. CreateReducedStream builds the child's sync/{id} sub-hub under stream.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 NACK DataExtensions.HandleGetDataRequest's disposal arm owes it — it hangs for its whole budget. ReduceShared bypasses the cache entirely once the parent is disposed, falling through to a plain Reduce so the behaviour is exactly what it always was. SilentReadNackTest pins 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:

  1. stream.Update(fn) posts an UpdateStreamRequest to the local sync hub.
  2. The sync hub executes the update function and calls SetCurrent().
  3. The feedback subscription detects the change and converts it to PatchDataChangeRequest (JSON element streams) or DataChangeRequest (typed streams).
  4. The change is forwarded to the owner hub: hub.Post(e, o => o.WithTarget(owner)).
  5. The owner applies the change using its registered PatchFunction.
  6. 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.

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