Languages, markets and channels
Explain how values vary by context (for example a name per language), and how entities belong to contexts (for example the products in the web shop).
Calculated context memberships
Vox determines candidate contexts separately for every entity/context type pair. If inheriting relation paths exist, only the contexts reached through those relations are candidates. If a membership calculation exists without a relation path, every stored entity of the context type is a candidate. There can be one IVoxContextMembershipCalculation<TEntity, TContext> for each pair.
This makes it possible to use relations for one context and calculations alone for another. For example, products can have explicit web/app channel relations while their market memberships are inferred from price and inventory data:
public class ProductMarketMembership : IVoxContextMembershipCalculation<Product, Market>
{
public Task<bool> IsMemberAsync(
Product product,
Market market,
IVoxCalculationContext context,
CancellationToken cancellationToken)
{
return Task.FromResult(
product.PriceMarketIds.Contains(market.Id) &&
product.InventoryMarketIds.Contains(market.Id));
}
}
Because Product has no inheriting relation path to Market, Vox tests every stored market. Creating, updating or deleting a market triggers these calculated-only memberships again. If a Product-to-Market path is later added, Vox automatically switches the pair to relation-derived candidates and the same calculation narrows that candidate set.
The next example assumes a relation-derived product/market membership and keeps it only while the product is published. RecalculateAt records the boundaries at which the answer may change; Vox schedules durable recalculations even when the current result did not change.
public class PublishedProductMarketMembership(TimeProvider timeProvider)
: IVoxContextMembershipCalculation<Product, Market>
{
public Task<bool> IsMemberAsync(
Product product,
Market market,
IVoxCalculationContext context,
CancellationToken cancellationToken)
{
if (product.PublishedFrom is { } publishedFrom)
{
context.RecalculateAt(publishedFrom);
}
if (product.PublishedTo is { } publishedTo)
{
context.RecalculateAt(publishedTo);
}
var now = timeProvider.GetUtcNow().UtcDateTime;
return Task.FromResult(
(product.PublishedFrom == null || product.PublishedFrom <= now) &&
(product.PublishedTo == null || now < product.PublishedTo));
}
}
Membership calculations can also depend on the stored memberships of related entities. The batch read records a dependency for every supplied entity, including those with no current membership, so creating or removing a membership later triggers the calculation again.
public class BundleMarketMembership : IVoxContextMembershipCalculation<Bundle, Market>
{
public async Task<bool> IsMemberAsync(
Bundle bundle,
Market market,
IVoxCalculationContext context,
CancellationToken cancellationToken)
{
var products = await context.LoadRelatedAsync<Product>(bundle, cancellationToken);
var marketIdsByProduct = await context.GetContextIdsAsync<Market>(products, cancellationToken);
return products.Any(product => marketIdsByProduct[product.Id].Contains(market.Id));
}
}
RecalculateAt is available to ordinary calculated-property calculations as well. Local DateTime values are converted to UTC, unspecified values are treated as UTC, past instants are ignored, and only the earliest future instant requested by a run is retained.