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);
An instance with no key value is skipped, not fatal. When a batch carries an instance whose key
resolves to null, that instance is reported in the activity log under
activity.dataUpdate.nullKeyInstancesSkipped (with its count and collection) and skipped, while every
keyed instance of the same batch still applies and the data stream stays live for later writes. A
type with no key MAPPING at all is a configuration error and still fails the change.
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.
Delete only if unchanged — ExpectedVersion
A caller that reads a node, decides from what it read that the node may go, and then deletes it has a race: another writer can change the node between that read and the delete, and the delete removes the change. You cannot close that race from the caller's side. Checking again just before deleting leaves the same window. So name the version you read, and let the owner check it:
var request = new DeleteNodeRequest(path) { ExpectedVersion = node.Version };
hub.Observe(request, o => o.WithTarget(hub.NodeOperationTarget()))
.Subscribe(
d =>
{
if (d.Message.RejectionReason == NodeDeletionRejectionReason.Conflict)
{
// It changed after we read it. Nothing was deleted: re-read and decide again.
}
},
ex => logger.LogWarning(ex, "Delete of {Path} did not answer", path));
Where it is checked: by the owning node hub, in its commit turn. The delete opens the subtree-deletion scope, then asks the owning hub to compare
ExpectedVersionwith the node's live version. The owner answers on the same turn every own write and every cross-hub patch commits on. So every write the owner has already acknowledged is in the version it compares.The owner holds the seal. When the version matches, the owner seals the path in that same turn, in its own process, so the seal holds whichever process or silo runs the delete. While the seal is held, an own write to the path is deferred: it is neither acknowledged nor refused. Its storage guard also refuses writes of the row, so a cross-hub patch is refused at its durable flush (
NotFound, "Node being deleted"). And the owner confirms only once every write of the row that its process admitted before the seal has settled. Such a flush, acknowledged before the check and already below the guard, would otherwise land after a delete on another silo and store the node again. The check and the delete are one step: nothing acknowledged can land between them.Released on the delete's commit or abort message. When the delete finishes, it sends
ReleaseDeleteSealwith its outcome. The message goes to the node-operation hub of the owner's process, which the owner names in its answer. That address still resolves after the row is gone; the node's own address does not. A successful delete waits for that hub to acknowledge the release before it answers, so a caller that recreates the path at once is not refused by the owner's row guard. If no answer came back from the owner, the abort goes to the node itself. If it gets there before the version check runs, it leaves a mark for that activation, and the check then refuses to take the seal.- Committed (the root row is gone): the owner tombstones the path, and every deferred write is
refused with
SubtreeDeletionInFlightException. Nothing it deferred was acknowledged, and its pending flush cannot store the node again. A fresh own write on that still-live owner is refused too: every own write on a tombstoned path is refused, so the owner never acknowledges a write its save path would then drop. - Aborted (the delete gave up and its root storage delete was never issued, or answered:
for example
ConflictorHasChildren): the deferred writes run their commit turn again and are applied. - Indeterminate (the delete gave up while its root storage delete may still be running, for example when the commit stage timed out after issuing it): neither a commit nor an abort is known, because unsubscribing does not stop a storage call that is already running. The owner runs the store fence below, while it still lives, and its writes wait until the fence has decided. The deleter can tell these apart because its root storage call is observed by the mesh, not by the delete's pipeline. The call is subscribed once by the registry and runs to its real end, and the store's answer is recorded there. The release asks that record, not how far the pipeline got: a removal is Committed even when the pipeline was cut before it noted it. An unanswered call is Indeterminate, and so is a FAILED one: an error proves nothing about the row, because one provider can remove a copy before another faults. The record belongs to one delete (it is keyed by that delete's seal id, never by the path) and is consumed by that delete's release, so a concurrent delete of the same path can never read it or misclassify a later delete. The record is opened before the delete issues any work and closed by the release in the same step that reads it, so a root delete that tries to start after the outcome was decided is refused before it reaches the store. A call that is still running decides first: the release is Indeterminate even when an earlier drain pass already removed the row, because that call could still remove a node created afterwards.
- The deleter died: no message comes. The seal lasts exactly as long as the owning activation. Its teardown refuses the deferred writes, fences the row (below) and then unseals the path for the next activation. No timer releases a seal: one that fired while the delete was still running would let through a write that the delete then removes.
- Committed (the root row is gone): the owner tombstones the path, and every deferred write is
refused with
Why the live version, not the stored one. An own write is acknowledged from the owner's in-memory commit, about one persistence-sampler interval before it is stored. A check against the stored row matched the old version in that window, and the delete removed a change its writer had been told was accepted (#6673).
A mismatch answers
Success = falsewithRejectionReason = Conflict, the same optimistic-concurrency fact asMeshNodeErrorCode.Conflicton an update. Nothing is deleted.An absent node still answers the idempotent success
AlreadyAbsent(Deleting What Is Already Gone). The postcondition holds, whatever version the node last had.And checked again by the store, in the delete itself. The root row is removed with a compare-and-delete (
IStorageAdapter.DeleteIfVersionAtMost; on PostgresDELETE … WHERE version <= $expectedand the row count). A row newer than the confirmed version was written after the owner's check, by a writer that never passed the owner (anything that writes the store directly). The store refuses to remove it, and the delete answersConflictnaming the stored version. A row at or below the confirmed version is the node the deleter read, one sampler flush behind, so it is removed. So the guarantee does not rest on the seal alone. Only a row in writable storage counts as a survivor. Shipped content that a read-only provider serves under a removed writable row is not this delete's to remove, so it is no conflict.When the same path has copies in several writable providers, a copy newer than the confirmed version refuses the whole delete and nothing is removed. The version the conflict names is the first copy a read finds, which can be the older one. Removals across separate stores are not atomic: a newer copy written into a second provider while the delete runs is refused there, but an older copy already removed is not restored.
Root only. For a recursive delete, only the node at
Pathis checked. The cascade's per-descendant requests never carry the value. The cascade removes children before their parent, so when the store refuses the root, the descendants are already gone. The response names them, and the root survives.Which version to name. The version you read, from the node stream or from an acknowledged write. It is compared with what the owner holds, so a version that is acknowledged but not yet stored matches. The node a cross-hub
GetMeshNodeStream(path).Update(...)emits carries the version the owner committed (PatchDataResponse.CommittedVersion), so it can be named directly. Its content is still your writer's view: fields that a concurrent writer merged in on the owner are not in it. An owner on an image older than this field does not send it, and the emitted node then carries your base version, one behind the owner. Naming that version is refused asConflict; re-read the node stream and use its version.No verdict is a refusal. If the owner cannot be asked and the node is still stored, the delete answers
Unavailableand removes nothing. It never treats an unanswered check as a match. The same holds when the storage read that would tell "gone" from "unreachable" fails as well.A match must be confirmed. The owner's "matches" answer echoes the version it compared (
ValidateDeleteResponse.ConfirmedVersion). During a rolling update, an owner on an older image does not knowExpectedVersion: it ignores the field, runs the ordinary delete validators and answers a plain "valid". That answer carries no echo, so the delete reads it asUnavailable, not as a match.A recycling owner is asked again. If the owner's activation is torn down while it compares, it answers
ShuttingDown, as it does for an ordinary delete check. The delete then asks the next activation, within the same time budget.Every seal transition is serialized. Taking a seal, a release (commit, abort, indeterminate), an abort-before-check mark, the teardown, the start of a fence, a fence's completion, a fence retry, settling and removal are each ONE command on ONE serialized lane in the owner's process (a
Subject+Select(Run)+Concat). No two transitions can interleave. The lane belongs to the process, not to the owning activation, because a seal must outlive the activation that took it: its teardown is exactly when the fence runs. Seal state is immutable and replaced by a command, and the commit turns that defer own writes only read the published snapshot. A command tombstones a committed path before it publishes the path as unsealed, so a reader that sees the path unsealed already sees the tombstone. The fence's store I/O runs off the lane, on the I/O pool (owner-delete-fence). Its completion comes back as a command that names its attempt, so a stale attempt can never settle the retry that replaced it. Events and answers a command decides run after it, outside the lane.The owner is torn down while it holds the seal. Its deleter may still be alive and paused before the store. So the teardown first refuses the writes it deferred, then fences the stored row: a compare-and-set (
WriteIfVersion) moves the row one version past the confirmed version, carrying the confirmed state. The paused delete's compare-and-delete then refuses it, and the delete answersConflict. While the fence runs, or after it failed, the owner's row guard stays: the fence's own write is the only one admitted past it (admitted by instance), so a cross-hub patch's durable flush is still refused rather than made durable behind the paused delete. A replacement activation in the same process defers its own writes until the fence has landed. If the fence finds the writable row already gone, the delete won: the path is tombstoned, and nothing deferred stores it again. The fence moves only the confirmed node's own writable row. It never writes over read-only fallback content (version 0, which would recreate a removed override), never over a node that was deleted and created again (a differentCreatedDate), and it stops once the delete's own commit or abort has arrived. A commit that arrives during the fence is answered only after the fence has terminated. A fence that fails settles nothing: the path stays sealed until the delete's commit or abort arrives, and the next own write the seal defers runs the fence again. A deleter that died leaves exactly this case, so no timer is needed. One window remains: a replacement activation on another silo is not deferred. It can acknowledge a write in the moment between the teardown and the fence landing, and that write is removed only if the paused delete also reaches the store in that same moment. The window is the fence's own write latency, not the length of the delete's pause.An unknown outcome releases nothing.
DeleteSealOutcomeis an open vocabulary. A release that names a value this process does not know is reported, and the seal stays until a known outcome or the owner's teardown releases it.A save the seal refused is resumed on abort. An own write acknowledged just before the seal can reach the persistence sampler while the path is sealed, and the row guard refuses that save. When the seal lifts without a delete, the owner re-dispatches the pending save through its own save pipeline, so the acknowledged state is stored without waiting for the next change. The re-dispatch is a payload-free nudge to the persistence sampler, which reads the newest unsaved state itself at its next tick. No snapshot captured elsewhere can arrive after a newer own write and be posted over it. If the row guard's refusal arrives only after the seal has already lifted, which means the lift's tick found the save still marked as requested, the refusal itself nudges the sampler. A refusal that arrives while the seal still holds nudges nothing, so there is no retry against a guarded row. Either ordering schedules the save exactly once. The sampler marks a save as dispatched before it posts it, and rolls that mark back if the post is not accepted. A refusal that comes back inside the post therefore always finds the save to mark pending again.
An early abort leaves state only for a sender who may delete the node. The owner checks the sender's
Deletepermission explicitly before it records an abort mark, because the message itself carries no permission attribute. AndReleaseDeleteSealis[InfrastructureOnly]: a remote participant cannot send it at all.The seal and the persistence sampler, ordering by ordering. An own write is acknowledged from memory and stored later by the persistence sampler. While a delete seal is in play, every acknowledged state X must end persisted (X or a newer state is stored), or superseded: the delete committed and the path is tombstoned, or the store fence wrote the confirmed state, which contains X.
Terms used in the table:
- The sampler is the only poster of own saves. Every own arrival and every nudge is a trigger only. At each tick the sampler reads the newest unsaved state itself.
- Reserve, then post: a save is marked requested before it is posted, and the mark is rolled back if the post is not accepted.
- Refusal means the row guard rejected the write. The save becomes pending again, and the sampler is nudged only if no guard still holds.
- The final flush trusts only a save that is persisted, never a save that is merely reserved.
Ordering Ends as X, tick, post, write OK persisted X, seal, tick, post, refusal, abort the abort is the last guard to end and nudges the sampler, so X is posted: persisted X, tick, reserve, refusal inside the post (sealed), abort the refusal finds X reserved and makes it pending; the abort nudges the sampler: persisted X, tick, post, seal, abort, tick (X still requested), then refusal the refusal sees no guard holding and nudges the sampler itself: persisted resume nudge held, X+1 written and posted, nudge released the tick reads the newest state; nothing older is posted: X+1 persisted any of the above, then commit tombstoned before the path is unsealed; deferred writes are refused (never acknowledged): superseded indeterminate, fence moves the row settles as lifted, the sampler is nudged: persisted indeterminate, fence finds the row gone settles as committed, tombstoned: superseded indeterminate fence fails, then owner teardown the teardown retries the fence, which carries the confirmed state: superseded by the fence seal plus an overlapping unversioned delete, seal aborts first, other delete aborts the subtree or row scope's end is announced too ( DeletionGuardsLifted), so the last guard to end nudges the sampler: persistedX reserved, final flush decides, post then rejected (shutdown) the final flush ignores the reservation and writes X: persisted owner teardown while held or fencing the fence writes the confirmed state; the dispose flush cannot pass the row guard: superseded by the fence X acknowledged after the version check impossible: every own write after the check is deferred until the seal settles A late root delete never removes a recreated node. Versions count per node, not per path: a node that is deleted and created again starts back at version 1, so "version at most V" alone would let a root delete that is still running remove the new node. The owner fences its own writes, but it cannot block a create made by another hub. So the root removal also compares the node's incarnation:
IStorageAdapter.DeleteIfIncarnationAtMost(path, V, incarnation)removes the row only when its version is at most V and it is the incarnation the deleter read. The incarnation isMeshNode.CreatedDate. It is set once, when the node is created, every upsert keeps it, every store already persists it, and a recreate mints a new one. It is compared in UTC at microsecond precision, the precision Postgrestimestamptzkeeps (StorageIncarnation). On Postgres this is one statement,DELETE … WHERE path = $1 AND version <= $2 AND created_date = $3, in MeshWeaver.Plugins. A recreated node is refused, survives, and the delete answersConflict. Limits: a copy, import or restore that keeps the originalCreatedDatecounts as the same incarnation. A row stored without aCreatedDate(older data, and Markdown nodes on the file-system store, whose front matter does not carry it) has no incarnation to compare, so the delete falls back to the version-only check. Regression:ALateDeleteNeverRemovesARecreatedNodeTest(two silos; a held late delete, a delete and recreate on the other silo, then the release: the recreated node survives).Not covered yet. Only the in-memory and Postgres stores implement
DeleteIfVersionAtMost,DeleteIfIncarnationAtMost(and the fence'sWriteIfVersion) atomically (Postgres in MeshWeaver.Plugins, as one conditionalDELETEwhose row count is the verdict; its path router forwards the call). The file-system store (and the caching adapter over it) and Snowflake use the interface default: they read, compare, then delete. That checks the version but not atomically, so a write landing between the read and the delete is removed. On the file-system backend that matters only with more than one writer per directory; it is a single-writer store. Snowflake is tracked separately, in bug triage. And when the store refuses a delete because of a write from another process, the owner here adopts that write only when its change notification, or a declared feed gap, arrives. Until then the node stream can still show the older version, and a retry naming it is refused again. That is safe, because nothing is removed, and the conflict names the stored version.Omitted (
null, the default), the delete removes whatever version is there, exactly as before.
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