Nexus.Stratum (0.1.0-alpha.6)
Installation
dotnet nuget add source --name NEXUS --username your_username --password your_token dotnet add package --source NEXUS --version 0.1.0-alpha.6 Nexus.StratumAbout this package
STRATUM: the NEXUS storage abstraction. Typed storage contracts (event stores first) over in-memory / browser / git backings, for Blazor WASM and regular .NET.
Nexus.Stratum
The NEXUS storage abstraction: one library exposing a family of typed storage contracts over pluggable backings, for Blazor WASM and regular .NET alike. The event store is the first contract species; a blob/asset species follows when its first concrete consumer exists.
- F# on .NET 10. License: AGPL-3.0-or-later.
- Design doc (living, decision-cited):
model.fsx. - Decisions and findings: NEXUS
CHRONICON(events taggedstratum).
Install
Published to the NEXUS org NuGet feed on Forgejo
(https://forgejo.ivanthegeek.com/api/packages/NEXUS/nuget/index.json).
Bind an exact pre-release pin (floating ranges do not resolve
pre-release overlay versions):
<PackageReference Include="Nexus.Stratum" Version="0.1.0-alpha.3" />
For FSI / .fsx exploration, reference the package, not a consumer DLL
(a class-library consumer's bin/ does not contain Nexus.Stratum.dll,
only the NuGet cache does):
#r "nuget: Nexus.Stratum, 0.1.0-alpha.3"
open Nexus.Stratum
Core concepts
StoredEvent<'e>-- the recording envelope (Id,StoredAt,Origin) around your domain fact'e. Its representation is private: the only constructor isStoredEvent.mint, soStoredAt/Originare system-asserted, not forgeable.EventStore<'e>--Append/ReadAll/Subscribe. Append-only; one store instance is one logical stream. Replay order isStoredAtascending, ties byEventId. Re-appending a value-identical event is an idempotent no-op; the same id with different content is refused loudly.PayloadCodec<'e>-- the one thing your app supplies: its event DU to/from the wire. STRATUM owns everything else about the file (envelope keys, the[event]section, the TOML frame).- Backings --
InMemorytoday (tests, FSI, GUI-state stores); browser (IndexedDB/OPFS) and git-remote sync follow behind the same contract.
Quickstart
open System
open Nexus.Stratum
// 1. Your domain event DU.
type Vital =
| GlucoseTaken of int
| NoteAdded of string
// 2. A PayloadCodec. Decode MUST positive-match its field set --
// FileFrame guarantees key SHAPE only and passes every [event] field
// through, so use PayloadCodec.expectKeys to reject unknown/extra keys
// (silent-accept is a loud-failure-doctrine violation).
let codec : PayloadCodec<Vital> =
{ Encode =
function
| GlucoseTaken mg -> CaseName "GlucoseTaken", [ "mg_dl", TomlInt (int64 mg) ]
| NoteAdded text -> CaseName "NoteAdded", [ "text", TomlString text ]
Decode =
fun caseName fields ->
let (CaseName case) = caseName
let find k = fields |> List.tryFind (fun (key, _) -> key = k) |> Option.map snd
match case with
| "GlucoseTaken" ->
PayloadCodec.expectKeys caseName [ "mg_dl" ] fields
|> Result.bind (fun () ->
match find "mg_dl" with
| Some (TomlInt mg) -> Ok (GlucoseTaken (int mg))
| _ -> Error (CodecError "GlucoseTaken: mg_dl must be an integer"))
| "NoteAdded" ->
PayloadCodec.expectKeys caseName [ "text" ] fields
|> Result.bind (fun () ->
match find "text" with
| Some (TomlString t) -> Ok (NoteAdded t)
| _ -> Error (CodecError "NoteAdded: text must be a string"))
| other -> Error (CodecError (sprintf "unknown case '%s'" other)) }
Mint at the composition root
Build the minter once at the composition root with the real clock and
this replica's Origin, then thread the resulting 'e -> StoredEvent<'e>
through the app. The clock is read once per event and asserts both
StoredAt and the UUIDv7 id, so per-writer id and time order never
diverge.
// Composition root (once):
let clock : Clock = fun () -> DateTimeOffset.UtcNow // inject a fixed clock in tests
let origin = Origin (Guid.CreateVersion7()) // minted once per installation, kept in config
let recordEvent : Vital -> StoredEvent<Vital> = StoredEvent.mint clock origin
let store : EventStore<Vital> = InMemory.create ()
// Anywhere downstream:
async {
let! result = store.Append (recordEvent (GlucoseTaken 112))
let! all = store.ReadAll () // converged union, in replay order
return result, all
}
Subscribe pushes each newly appended event exactly once (never for
idempotent re-appends); handlers run inside the Append workflow, so keep
them fast.
File frame
FileFrame.encode/decode map a StoredEvent<'e> to/from one TOML file
per event (<uuidv7>.toml), composing your payload codec around the
STRATUM-owned envelope. Both are total (Result<_, CodecError>, never
throw) and name the offending input on failure.
Testing your codec
Field-set guard. Pin that an unexpected or missing payload field is
refused -- PayloadCodec.expectKeys makes this the default path.
Golden tests. Mint with a fixed Clock, then reconstruct the
expected file text from the minted event's own Id/StoredAt/Origin,
pinning literally only the payload bytes you own (the case line and the
[event] block). Do not pin a literal EventId: the envelope frame
is STRATUM's golden-test responsibility (its own tests freeze it), and
reconstructing from the minted event asserts the real invariant -- encode
produced this event's frame -- rather than a brittle hard-coded id.
let fixedClock : Clock = fun () -> DateTimeOffset(2026, 1, 1, 12, 0, 0, TimeSpan.Zero)
let e = StoredEvent.mint fixedClock origin (GlucoseTaken 112)
let (EventId gid) = e.Id
let (Origin oid) = e.Origin
let expected =
[ "id = \"" + gid.ToString("D") + "\""
"stored_at = " + e.StoredAt.ToString("o")
"origin = \"" + oid.ToString("D") + "\""
"case = \"GlucoseTaken\""
""
"[event]"
"mg_dl = 112"
"" ]
|> String.concat "\n"
// Expect.equal (FileFrame.encode codec e) (Ok expected) ...
Build
Containerized, pinned SDK -- never bare dotnet:
./build # restore + build + test
./build pack # produce ./out/*.nupkg
./build lock # regenerate packages.lock.json (after any PackageReference change)