Independent/periodic info register
Unlike Inventory and ExchangeRates, nothing in Kandra's
reference configuration uses a PeriodicInfoRegister<>. It is fully implemented and
DI-wired — the engine emits its registration the moment such a record type exists — but it has
not been proven under a real workload. The register below (ItemMinStock) is illustrative:
built from the engine's own source and the create-register skill's data/PeriodicInfo.cs
template, not copied from working code. Expect to be the first to find any rough edges, and
check first whether a document-bound Info register would do the job.
This is the one register family that is not written through document posting. Where a document-bound Info register's records belong to the document that posted them (and vanish when it's unposted), a periodic Info register holds reference-style state — values with an effective date that are set and cleared directly: by an import job, an admin screen, a dictionary behavior. Think "minimum stock level for this item at this warehouse, effective from this date", edited by a manager, not derived from any transaction.
Read Creating a register first for the shared concepts. Acme stands in for your
configuration's root name.
How it differs from document-bound Info
| Document-bound | Independent/periodic | |
|---|---|---|
| Base record | DocumentInfoRecord<TDim,TDetails> | PeriodicInfoRecord<TDim,TDetails> |
| Structural fields | Period, DocumentId, LineId, Active | Period only |
| Written by | A document Behavior, via keyed IDocumentInfoRegisterWriter<> | Any service, via plain injectable IInfoRegisterWriter<,,> |
| Write semantics | Replace the document's whole record set | Upsert / delete one value by (Dimensions, Period) |
| Register transactions tab | Yes (needs [RegisterCaption]) | No — no DocumentId to attribute rows to |
Because there is no document, there is no recorder, no unposting, and no Active flag: a value
is either present or deleted.
1. Dimensions, Details, record, register
Acme.Domain/Entities/Registers/ItemMinStock.cs:
using System.Text.Json.Serialization;
using Kandra.Attributes.Entities;
using Kandra.Attributes.Naming;
using Kandra.Attributes.Printable;
using Kandra.Domain.Registers;
using Acme.Domain.Entities.Dictionaries;
using Microsoft.EntityFrameworkCore;
namespace Acme.Domain.Entities.Registers;
public sealed class ItemMinStockDetails : IRegisterDetails
{
[Precision(14, 3)]
[Caption("MinQuantity")]
[Format("N3")]
public decimal MinQuantity { get; set; }
}
[KandraGeneratedEquality]
public sealed partial class ItemMinStockDimensions : IRegisterDimensions<ItemMinStockDimensions>
{
[Caption("Warehouse")]
public Guid WarehouseId { get; set; }
[JsonIgnore]
public Warehouse Warehouse { get; set; } = null!;
[Caption("Item")]
public Guid ItemId { get; set; }
[JsonIgnore]
public Item Item { get; set; } = null!;
}
[KandraRegisterEntity(TableName = "Reg_ItemMinStock")]
public sealed class ItemMinStockRecord : PeriodicInfoRecord<ItemMinStockDimensions, ItemMinStockDetails>
{
}
public sealed partial class ItemMinStockRegister : PeriodicInfoRegister<ItemMinStockRecord>
{
}
Compared with the document-bound record, note what is absent: no [RegisterCaption] (the
register transactions viewer only reads Movement and DocumentInfoRecord rows, so this record
type never appears in it — the caption would be dead weight), and the same "no Resources class"
rule as every Info register — the stored value is Details.
2. DI and EF configuration — nothing to write
Build, then confirm the generated persistence DI extensions contain:
services.AddPeriodicInfoRegister<ItemMinStockRecord, ItemMinStockDimensions, ItemMinStockDetails>();
It registers the reader (IInfoRegisterReader<ItemMinStockRecord>) and a plain
IInfoRegisterWriter<ItemMinStockRecord, ItemMinStockDimensions, ItemMinStockDetails> — an
ordinary scoped service, not keyed. EF configuration is generated as for every family.
3. Localization
MinQuantity, Warehouse, Item in all three resx files (the latter two probably already
exist).
4. Write it from any service
There is no ISubmitScope here. Inject the writer like any other dependency:
public class ItemMinStockService(
IInfoRegisterWriter<ItemMinStockRecord, ItemMinStockDimensions, ItemMinStockDetails> writer)
{
public Task SetAsync(Guid warehouseId, Guid itemId, DateTime effectiveFrom, decimal minQuantity,
CancellationToken cancellationToken) =>
writer.SetAsync(
new ItemMinStockDimensions { WarehouseId = warehouseId, ItemId = itemId },
effectiveFrom,
new ItemMinStockDetails { MinQuantity = minQuantity },
cancellationToken);
public Task DeleteAsync(Guid warehouseId, Guid itemId, DateTime effectiveFrom,
CancellationToken cancellationToken) =>
writer.DeleteAsync(
new ItemMinStockDimensions { WarehouseId = warehouseId, ItemId = itemId },
effectiveFrom,
cancellationToken);
}
Register the service in your Acme.Application DI as you would any application service.
The writer contract is two calls:
SetAsync(dimensions, period, details, ct)— an upsert keyed by exactly(dimensions, period). If a record with that exact key exists itsDetailsare overwritten; otherwise a new record is inserted. It does not touch any other period for the same dimensions, which is what makes the values "periodic": eachSetAsyncfor a new effective date adds a new point in that dimension's history.DeleteAsync(dimensions, period, ct)— physically removes the record for exactly that key; a no-op if it doesn't exist.
5. Read it back
The reader is the same IInfoRegisterReader<TRecord> used by document-bound registers:
var current = await reader.SliceOfLast(
DateTime.UtcNow,
q => q.Where(r => r.Dimensions.WarehouseId == warehouseId),
cancellationToken);
SliceOfLast(at, ...) returns, per dimension combination, the record with the latest Period
at or before at — "the min-stock level currently in effect". Records(from, to, ...) returns
the history in a range. See Document-bound info register for
the details, including that SliceOfLast groups in memory (so always pass an adjust).
6. Migration and verify
Normal per-provider migration flow (Database migrations); dotnet build and
check the generated DI line and record configuration exist. There is no live example to model
tests on — write a SQLite round-trip test of SetAsync → SliceOfLast → DeleteAsync (the
same shape as PriceRecordTransactionsReaderSqliteTests, minus the document), since this is
exactly where an unproven family is most likely to surprise you.
Gotchas specific to this family
- The writer commits.
SetAsyncandDeleteAsynceach callSaveChangesAsyncon the scope's sharedDbContextthemselves, so calling one from inside a larger unit of work also commits whatever else is pending in that scope. Don't call them mid-way through something you'd expect to be able to roll back. - Uniqueness of
(Dimensions, Period)is by convention, not by index. The record table indexesPeriodbut has no unique constraint over the dimensions.SetAsyncfinds the existing row with a lookup-then-insert, so two concurrentSetAsynccalls for the same new key could both insert. If that can happen in your workload, serialize writes for a given key. Periodmatching is exact.SetAsync/DeleteAsyncmatch on theDateTimeexactly, not "the same day". Normalize what you pass in (e.g. always a date at UTC midnight) or you'll create near-duplicate points instead of updating one.- No document, no audit trail of who wrote it. With no recorder there is no
DocumentIdlinking a record back to what produced it, and no Register Transactions tab entry. If you need that, use a document-bound register and put the editing in a document. - Nothing calls this from generated UI. There's no form for it (registers never have one); the admin screen or import job that writes it is yours to build.
See also
- Creating a register — shared concepts and cross-family Gotchas.
- Document-bound info register: ExchangeRates — the live Info family.
- Job scheduler — a natural home for the import job that would call
SetAsyncon a schedule.