Skip to main content

Read projections

Read projections turn one Vox entity into zero, one, or many documents for another system. Typical targets are search indexes, product feeds, caches, and data exports. They are separate from calculated properties: nothing is written back to the source entity.

Define a projection​

A document has a stable ID, and a factory returns the current documents for one source entity. Every factory declares an explicit version:

public record ProductDocument(
string Id,
string ProductId,
string Language,
string Name) : IVoxReadProjection;

[VoxReadProjection("1", Name = "product-search")]
public class ProductSearchProjection
: IVoxReadProjectionFactory<Product, ProductDocument>
{
public async Task<IReadOnlyList<ProductDocument>> ProjectAsync(
Product product,
IVoxCalculationContext context,
CancellationToken cancellationToken)
{
var languages = await context.GetAllAsync<Language>(cancellationToken);
return languages
.Where(language => product.Name.ContainsKey(language.Id))
.Select(language => new ProductDocument(
$"{product.Id}:{language.Id}",
product.Id,
language.Name,
product.Name[language.Id]))
.ToList();
}
}

The source entity is tracked automatically. Read other Vox data through IVoxCalculationContext: GetAsync, GetManyAsync, GetAllAsync, LoadRelatedAsync, GetContextIdsAsync, and IsMemberOfAsync all register dependencies. In the example, changing the language set queues every product; GetAsync<Language>(id) would only queue products that read that ID. Missing entities and empty membership reads are dependencies too, so creating the missing data triggers a build.

Memberships are loaded lazily. A factory that doesn't call a membership method performs no membership query. The first call is cached for the rest of that source evaluation:

if (await context.IsMemberOfAsync<Channel>(product, "app", cancellationToken))
{
// Add app-specific fields.
}

RecalculateAt is available for time-dependent documents. Register the exact future instant at which the output can change; Vox keeps the earliest future instant and durably schedules that source.

Document IDs must be unique within a projection and stable across builds. Vox stores a source-to-document manifest, so an incremental run emits deletes when a source returns fewer documents or is deleted.

Define the sink​

Exactly one sink is required for a document type:

public class ProductSearchSink : IVoxReadProjectionSink<ProductDocument>
{
public Task<IVoxReadProjectionSinkSession<ProductDocument>> OpenAsync(
VoxReadProjectionBuild build,
CancellationToken cancellationToken)
{
// Return a session for this build.
}
}

The session receives pages of Upsert and Delete changes through WriteAsync. It can send each page immediately, buffer into API-sized chunks, or retain the complete list. CompleteAsync is its commit point—for example, publishing a feed or swapping a newly built search index into place. Sink operations are delivered at least once, so external upserts and deletes should be idempotent by projection ID. A failed sink keeps the sources pending for retry and does not block Vox entity saves, settlement, or events.

Context-scoped projections​

A context-scoped factory runs as an independent target for each context entity. Vox checks membership in batches and only passes members to the factory:

[VoxReadProjection("1", Name = "channel-products", ContextIds = ["app", "web"])]
public class ChannelProductProjection
: IVoxContextReadProjectionFactory<Product, Channel, ChannelProductDocument>
{
public Task<IReadOnlyList<ChannelProductDocument>> ProjectAsync(
Product product,
Channel channel,
IVoxCalculationContext context,
CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<ChannelProductDocument>>([
new($"{channel.Id}:{product.Id}", product.Id, channel.Id)
]);
}
}

ContextIds is optional. Without it, every stored entity of Channel becomes a target. The matching sink implements IVoxContextReadProjectionSink<Channel, ChannelProductDocument> and receives both the build and current channel. Its channel argument is null only while Vox delivers deletion changes after that channel entity has been deleted; the deleted identity remains in build.Context.

Each target has its own active version, manifests, dependencies, delayed work, and errors. Entering a channel produces upserts, leaving it produces deletes, and changing the channel entity rebuilds its members. Targets of one projection run sequentially.

Incremental and full builds​

Entity, relation, membership, and dependency changes durably queue affected source IDs. The projection's generated Nexus job processes that incremental work and advances the manifest only after the sink completes successfully.

A full build visits every current source, sends all current documents plus deletes for stale documents, and replaces the manifest. Change the attribute's Version whenever projection logic or its external schema changes. Incremental processing pauses until a successful full build activates the configured version, preventing a mixed old/new index. The first build is therefore a full build too.

Use the API to inspect registered projections and state:

GET /api/read-projections

Explicit builds are Nexus jobs, not Vox HTTP operations. Start Vox read projection build in the Nexus UI and set:

ProjectionName: channel-products
Kind: Full
ContextId: app

Leave ContextId empty to build every allowed context sequentially. The manual job and automatic incremental queue job share the projection's Nexus mutex, so a full build cannot overlap incremental work. The demo contains both a complete 1×N product/language projection and a channel-scoped projection.