Table of Contents

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/.)