Extension Building Blocks
The seams on Extending Core Components say where you plug in. The building blocks on this page are the reusable helpers you call from a seam so you don't re-roll the plumbing โ CRUD reconciliation, schema building, etag/id handling, validation, JSON parsing, trees, resilience, caching. They live in one namespace:
using MediaiBox.Cms.FrontEnd.Server.Component.Extensions;
Related docs. Extending Core Components โ the seams these helpers are called from. Migration Cookbook โ real components using these blocks.
How to read this page โ three kinds of block
Not every block is the same maturity. Each entry is tagged:
- ๐ง Utility โ a standalone helper you call from your override. Available today; most blocks are this.
- ๐ Wired โ already invoked by a shipped seam (you mostly consume its types).
- ๐ง Scaffolding โ types for a capability that is not yet wired into the component pipeline (the seam that would drive it is deferred โ see What is not yet a seam). Use it as a stable contract to build against, but it won't be auto-invoked yet.
Why these exist โ the boilerplate they delete
The seams stop you re-rolling the whole component; these blocks stop you re-rolling the plumbing inside the override. That distinction is grounded in a sweep of the two production custom-component codebases โ ~17 behavior-overriding components in CMSE and ~217 in tvopenplatform-mib3 โ where the override bodies were dominated by the same hand-copied plumbing, not by business logic:
| Boilerplate found in the real code | Seen in | Block that retires it |
|---|---|---|
Permission gate (Permissions.Get(...) โ early return) |
nearly every component (all 17 Voucher components) | inherited permission computation + OnPermissionResolved (a seam, not a block) |
Copy-pasted JsonDocument parse helpers (TryGetRequestRoot/ReadIntArray/GetIntValueโฆ) |
~5 CMSE Content components | JsonHelpers |
| Composite-id math (index-prefix + entity id) to address JSON-array elements | CMSE DescriptionList, GenreList |
CompositeIdCodec |
| "diff submitted vs DB rows โ delete removed, upsert rest" loop | dozens of related lists | CrudReconciler / OrderedReconciler / BulkReconciler |
Hand-ordered if โ throw PersistenceValidationException chains |
~35 mib3 *Validation components |
ValidationRuleRegistry |
etag '0' omitted-entity trap; parent-batch-reference for new-parent saves |
related-list saves | EtagPolicy, RelatedSaveBuilder / LifecycleContext |
| UTC โ user-timezone conversion; counter resequencing | UsersCriteria, VoucherValidation/RedemptionSelection |
DateConverter; resequence via OrderedReconciler |
| guarded post-save side-effect (downgrade, don't fail); retry; correlation id | the Kafka / external-sync after-save components | PostSaveChain / SaveTransactionContext, Resilience, CorrelationContext |
Two design rules follow:
- Additive & opt-in. Every block is a helper your override calls โ none touches a base class, so it cannot change default behavior (the same byte-identical guarantee the seams give).
- Complementary to the seams. Seams = where to plug in; blocks = what to call inside. A seam override without the blocks would just re-hand-roll the plumbing above; the blocks without the seams would have nowhere clean to run.
Status โ mostly latent SDK surface. These ship alongside the seams as the toolbox component authors reach for. Today they are exercised by the test suite but have few/no production callers yet โ adoption lands as components migrate (see the Migration Cookbook). The ๐ง scaffolding subset backs capabilities whose driving seam is still deferred.
1. CRUD reconciliation
The centerpiece for collection saves: turn "the rows the client submitted" + "the rows currently persisted" into a create/update/delete plan, and dispatch each.
CrudReconciler ยท ICrudReconciler<TRow,TKey> ยท CrudDelta<TRow> ๐ง
public static CrudDelta<TRow> Diff<TRow, TKey>(
IEnumerable<TRow> current, IEnumerable<TRow> edited, Func<TRow, TKey> keyOf,
Func<TKey, bool> isNewKey = null, IEqualityComparer<TRow> changeComparer = null,
IEqualityComparer<TKey> keyComparer = null);
public static Task ReconcileAsync<TRow, TKey>(
IEnumerable<TRow> current, IEnumerable<TRow> edited,
ICrudReconciler<TRow, TKey> handler, Func<TRow, TKey> keyOf, CancellationToken ct, โฆ);
public interface ICrudReconciler<TRow, TKey>
{
Task OnCreate(TRow row, CancellationToken ct);
Task OnUpdate(TRow current, TRow edited, CancellationToken ct);
Task OnDelete(TKey id, CancellationToken ct);
}
CrudDelta<TRow> exposes Created, Updated (RowUpdate<TRow>{ Current, Edited }),
Deleted, Unchanged, IsEmpty. The reconciler owns the diff, dedup,
delete-before-add ordering, and submitted-order preservation; you implement
the three per-row hooks.
protected override void OnRelatedSaveReconciled(PersistenceComponentSaveRelatedEntitiesData saveData,
PostbackComponentRequest request)
{
// Drive your own create/update/delete from the reconciled set.
await CrudReconciler.ReconcileAsync(
current: LoadPersistedRows(),
edited: ReadSubmittedRows(request),
handler: this, // implements ICrudReconciler<Row,int>
keyOf: r => r.Id,
ct);
}
BulkReconciler ๐ง
Drives bulk-edit saves over many selected parents.
public static BulkMode ParseMode(string mode); // "add"/"remove"/"clear"/"replace"
public static Task DispatchAsync<TKey>(BulkMode mode, IEnumerable<int> parentIds,
IReadOnlyList<TKey> ids, IBulkReconciler<TKey> handler, CancellationToken ct,
ValidationErrors errors = null);
// IBulkReconciler<TKey>: OnBulkAdd / OnBulkRemove / OnBulkClear / OnBulkReplace / OnBulkPreValidate
public enum BulkMode { None, Add, Remove, Clear, Replace }
Replace = clear-then-add unless your handler overrides it. The framework owns
the enum parse and the per-parent iteration.
OrderedReconciler ๐ง
Sort-position math for ordered lists โ without re-persisting unchanged rows.
public static IReadOnlyList<OrderedRow<TRow>> AssignPositions<TRow>(IEnumerable<TRow> rows, int start = 0, int step = 1);
public static bool OnlyOrderChanged<TRow, TKey>(IEnumerable<TRow> current, IEnumerable<TRow> edited, Func<TRow, TKey> keyOf, โฆ);
public static IReadOnlyList<OrderedRow<TRow>> ChangedPositions<TRow, TKey>(IEnumerable<TRow> current, IEnumerable<TRow> edited, Func<TRow, TKey> keyOf, int start = 0, int step = 1, โฆ);
OnlyOrderChanged lets you take a cheap reorder-only path; ChangedPositions
returns the minimal set of rows whose position actually moved.
2. Save / persistence
RelatedSaveBuilder ๐ง
Build a related-entities save model from a reconciled added/removed id set โ with the parent batch reference handled (the easy-to-get-wrong part).
public static PersistenceComponentSaveRelatedEntitiesData BuildSimpleRelated(
IMibApiClientLibrary apiClient, BaseContext context,
string mediaType, string parentMediaType, string templateComponentKey, string parentTemplateComponentKey,
IEnumerable<int> parentIds, IEnumerable<int> addedIds, IEnumerable<int> removedIds,
string parentBatchReference = null);
EtagPolicy ๐ง
Concurrency/etag wire-format handling, including the etag='0' omitted-entity trap.
public const string MissingEntityEtag = "0";
public static IReadOnlyDictionary<int, string> Parse(string raw); // "id:etag;id:etag"
public static string Format(IReadOnlyDictionary<int, string> etags);
public static bool IsMissingEntityEtag(string etag);
public static IReadOnlyList<int> ChangedIds(IReadOnlyDictionary<int,string> current, IReadOnlyDictionary<int,string> submitted);
IdCodec ๐ง
Encode/decode component identity tokens โ including composite/array ids.
public interface IIdCodec<TKey> { string Encode(TKey key); TKey Decode(string token); }
Int32IdCodec.Instance; // simple int ids
new CompositeIdCodec(separator: '-'); // "12-7" style composite keys
SaveTransactionContext ยท SaveOutcome ๐ง
Coordinate save atomicity when a seam runs external side effects; register compensations that run in reverse (LIFO) if a later step fails.
public SaveTransactionContext(SaveAtomicityMode mode = SaveAtomicityMode.BestEffort);
public void OnCompensate(Func<CancellationToken, Task> compensate);
public Task CompensateAsync(CancellationToken ct);
public enum SaveAtomicityMode { BestEffort, FailFastStop, Compensating }
SaveOutcome.Committed(); SaveOutcome.Rejected("โฆ"); SaveOutcome.PartialSideEffectFailed("โฆ");
PostSaveChain ยท PostSaveResult ๐ง
An ordered chain of post-save side-effect handlers; results aggregate to the worst status (Error > SuccessWithWarning > Success), messages joined.
var chain = new PostSaveChain()
.Add(ct => PublishKafkaAsync(ct)) // order 0
.Add(ct => InvalidateCdnAsync(ct), order: 10);
var result = await chain.RunAsync(ct); // PostSaveResult
PostSaveResult.Success(customData);
PostSaveResult.Warning("CDN flush skipped", customData);
PostSaveResult.Error("publish failed");
PostSaveResult round-trips Status, ErrorMessage, opaque CustomData, and a
Flags bag โ map it onto the response in OnAfterSaveResponseBuilt.
3. Schema building
ListSchemaBuilder ๐ง
Fluent, additive column edits over a ListSchema or RelatedListSchema
(columns matched by DataIndex, case-insensitive). Mutates in place.
ListSchemaBuilder.For(schema) // ListSchema or RelatedListSchema
.AddColumn("PRIORITY", "Priority", c => c.Sorter = true)
.Override("STATUS", c => c.Title = "State")
.Hide("INTERNAL_NOTES")
.SetOrder("PRIORITY", 0);
Other members: Add(ColumnV2ViewData), Remove(dataIndex), Show(dataIndex),
Has(dataIndex).
FormSchemaBuilder ๐ง
The same idea over FormSchema.Fields.
FormSchemaBuilder.For(schema)
.OverrideWhere(f => f.Type == "text", f => f.Props = ReadOnly(f.Props))
.Remove(f => f.Type == "deprecated")
.SetTitle("Movie metadata");
4. Validation
Composable rules with dependency/cascade ordering and error aggregation โ runnable from a save seam (and shareable with a live-validation action later).
public sealed class ValidationErrors { IReadOnlyList<ValidationError> Items; bool HasErrors; void Add(string message, string field = null, string code = null); }
public interface IValidationRule<T> { string Name { get; } IReadOnlyList<string> DependsOn { get; } Task ValidateAsync(T model, ValidationErrors errors, CancellationToken ct); }
public sealed class ValidationRuleRegistry<T>
{
ValidationRuleRegistry<T> Add(IValidationRule<T> rule);
ValidationRuleRegistry<T> Add(string name, Func<T, ValidationErrors, CancellationToken, Task> validate, params string[] dependsOn);
Task<ValidationErrors> RunAsync(T model, CancellationToken ct, bool failFast = false);
}
var errors = await new ValidationRuleRegistry<MovieSave>()
.Add("title-required", (m, e, ct) => { if (string.IsNullOrEmpty(m.Title)) e.Add("Title is required", "TITLE"); return Task.CompletedTask; })
.Add("language-known", ValidateLanguageRule, dependsOn: "title-required")
.RunAsync(model, ct);
if (errors.HasErrors)
throw new PersistenceValidationException(TemplateComponentKey, errors.Items[0].Message);
5. Data / JSON / trees
JsonHelpers ๐ง
System.Text.Json read helpers โ case-insensitive, numeric-as-string tolerant,
semantic diffing. Stops every component from re-rolling JsonElement parsing
(the bulk of several CMSE classes).
JsonHelpers.TryGetProperty(el, "name", out var v); // case-insensitive
JsonHelpers.GetString(el, "title", fallback: "");
JsonHelpers.GetInt(el, "order"); // tolerates "3"
JsonHelpers.GetIntArray(el, "ids");
JsonHelpers.GetValue<MyDto>(el, "payload");
JsonHelpers.DeepEquals(a, b); // order-insensitive (like JToken.DeepEquals)
TreeBuilder ๐ง
Parent/child assembly for tree-shaped data โ cycle-safe.
var forest = TreeBuilder.BuildForest(items, idOf: x => x.Id, parentOf: x => x.ParentId);
var flat = TreeBuilder.Flatten(forest); // depth-first, parent before children
// TreeNode<T> { T Item; List<TreeNode<T>> Children; }
DateConverter ยท SaveTimingValidator ๐ง
Timezone conversion on load/save + temporal window checks for availability / scheduling components.
DateConverter.ToUserTime(utc, tz); DateConverter.ToUtc(local, tz);
SaveTimingValidator.IsOverlapping(s1, e1, s2, e2);
SaveTimingValidator.IsNowWithinWindow(start, end, now);
SaveTimingValidator.Contains(outerStart, outerEnd, innerStart, innerEnd);
6. Lifecycle & state
LifecycleContext ๐ง
First-class new/bulk/copy state so a seam can branch without re-deriving from
Context.IDs / CopiedFrom.
public bool ParentIsNew; public bool ParentIsBulk; public bool IsCopy;
public RelatedCopyType CopyType; public int CopiedFrom; public IReadOnlyList<int> ParentIds;
public IDictionary<string, object> StateBag; // stash state for a later stage
public int PrimaryParentId;
public string ResolveParentReferenceToken(string parentTemplateComponentKey);
public static LifecycleContext FromParentIds(IEnumerable<int> parentIds, bool isBulk = false, bool isCopy = false, int copiedFrom = 0);
ComponentRequestState ๐ง
A typed, thread-safe, request-scoped store that survives Save โ OnAfterSave
(stash something computed in the save seam to use in the after-save seam).
state.Set("publishIds", ids);
state.TryGet("publishIds", out int[] ids);
state.GetOrAdd("meta", () => LoadMeta());
7. Runtime / resilience / observability
Resilience ๐ง
Run an operation with a fallback on the read path / rethrow on the write path, with optional retry.
var rows = await Resilience.RunSafeAsync(
ct => _externalApi.ListAsync(ct),
fallback: Array.Empty<Row>(), // safe-empty when reading
throwErrors: false,
handle: ex => ex is TimeoutException,
retries: 2, cancellationToken: ct);
RequestFetchCache ๐ง
Request-scoped cache with async single-flight โ concurrent callers share one in-flight task; invalidate by key or media type.
var meta = await cache.GetOrFetchAsync(
RequestFetchCache.Key("MOVIE", "meta"),
ct => _meta.LoadAsync("MOVIE", ct), ct);
cache.InvalidateMediatype("MOVIE"); // e.g. from OnAfterSave
CorrelationContext ๐ง
A correlation id to tie seam telemetry and outbound calls together.
var corr = CorrelationContext.New();
httpRequest.Headers.Add(corr.ToHeader().Key, corr.ToHeader().Value); // "X-Correlation-Id"
RenderBudget ยท SeamTiming ๐ง
Per-component render budgeting (linked cancellation with timeout) + timing
telemetry (MapDataMs / MapSchemaMs / FetchCount).
using var cts = RenderBudget.CreateLinkedTimeout(ct, TimeSpan.FromSeconds(2));
Scaffolding for deferred capabilities ๐ง
These ship as stable contracts but are not yet wired into the pipeline (their driving seam is deferred). Build against them; don't expect auto-invocation yet.
| Type | For (deferred) capability |
|---|---|
ActionRegistry (Register, WithGuard, DispatchAsync), TransitionResult |
the custom-action pipeline (RegisterAction) + state-machine actions |
RenderFallback (Degrade/Propagate), ComponentRenderOutcome |
OnRenderError degraded render |
RequestOutcomeMap / RequestOutcome |
N-level secondary requests with partial-failure semantics |
At a glance
| # | Block | Kind | One-liner |
|---|---|---|---|
| 1 | CrudReconciler / ICrudReconciler / CrudDelta |
๐ง | create/update/delete diff + dispatch |
| 2 | BulkReconciler |
๐ง | bulk-edit add/remove/clear/replace over parents |
| 3 | OrderedReconciler |
๐ง | sort positions + order-only-change detection |
| 4 | RelatedSaveBuilder |
๐ง | reconciled ids โ related save model |
| 5 | EtagPolicy |
๐ง | etag wire format + etag='0' trap |
| 6 | IdCodec |
๐ง | composite/array id tokens |
| 7 | SaveTransactionContext / SaveOutcome |
๐ง | atomicity + compensations |
| 8 | PostSaveChain / PostSaveResult |
๐ง | ordered post-save side effects |
| 9 | ListSchemaBuilder |
๐ง | fluent column edits |
| 10 | FormSchemaBuilder |
๐ง | fluent field edits |
| 11 | ValidationRuleRegistry / IValidationRule / ValidationErrors |
๐ง | ordered validation rules |
| 12 | JsonHelpers |
๐ง | tolerant JSON reads + DeepEquals |
| 13 | TreeBuilder |
๐ง | cycle-safe forest build/flatten |
| 14 | DateConverter / SaveTimingValidator |
๐ง | timezone + window checks |
| 15 | LifecycleContext |
๐ง | new/bulk/copy state + state bag |
| 16 | ComponentRequestState |
๐ง | request-scoped typed store |
| 17 | Resilience |
๐ง | safe-fallback / retry wrapper |
| 18 | RequestFetchCache |
๐ง | single-flight request cache |
| 19 | CorrelationContext |
๐ง | correlation id propagation |
| 20 | RenderBudget / SeamTiming |
๐ง | render timeout + timing telemetry |
| 21 | ActionRegistry / TransitionResult |
๐ง | custom-action pipeline (deferred) |
| 22 | RenderFallback / ComponentRenderOutcome |
๐ง | degraded render (deferred) |
| 23 | RequestOutcomeMap / RequestOutcome |
๐ง | secondary-request partial failure (deferred) |
(Counts collapse a few closely-related types; the source of record is
MediaiBox.Cms.FrontEnd.Server/Component/Extensions/.)