Storage abstraction: one API over in-memory / files / git backings. First consumer: VitalsLog.
  • F# 92.8%
  • Shell 5.6%
  • JavaScript 1.3%
  • HTML 0.3%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Ivan The Geek fb1d2ab214
All checks were successful
CI / build-test (push) Successful in 30s
Publish package / publish (push) Successful in 33s
Merge chore/build-image-sync-caveat: declared build-image variant + CI guard
Adopt the .build-image.variant marker (wasm-node-playwright) so a
build-template sync preserves STRATUM's superset pin (LOGOS Decision
019f8cef-153c), add a ci.yml guard tying .build-image to CI, and rewrite the
AGENTS build-image note to the sync-safe mechanism.
2026-07-22 23:11:55 -04:00
.claude chore: bootstrap NEXUS/STRATUM repo with NEXUS compliance 2026-06-12 15:07:31 -04:00
.forgejo/workflows chore: adopt .build-image.variant marker + CI build-image pin guard 2026-07-22 23:11:53 -04:00
src feature: ship per-member XML API docs in Stratum packages 2026-06-14 02:46:20 -04:00
tests chore: switch STRATUM to the -wasm-node-playwright superset image 2026-06-14 02:37:04 -04:00
.build-image chore: switch STRATUM to the -wasm-node-playwright superset image 2026-06-14 02:37:04 -04:00
.build-image.variant chore: adopt .build-image.variant marker + CI build-image pin guard 2026-07-22 23:11:53 -04:00
.gitignore feature: containerized build via LOGOS build-template 2026-06-12 17:59:38 -04:00
AGENTS.md chore: adopt .build-image.variant marker + CI build-image pin guard 2026-07-22 23:11:53 -04:00
AGENTS.source.md chore: adopt .build-image.variant marker + CI build-image pin guard 2026-07-22 23:11:53 -04:00
build feature: containerized build via LOGOS build-template 2026-06-12 17:59:38 -04:00
CLAUDE.md chore: bootstrap NEXUS/STRATUM repo with NEXUS compliance 2026-06-12 15:07:31 -04:00
Directory.Build.props feature: containerized build via LOGOS build-template 2026-06-12 17:59:38 -04:00
global.json chore: repin SDK to 10.0.301 (template sync; host lockstep) 2026-06-12 20:01:29 -04:00
model.fsx docs: reconcile model.fsx with shipped code (drift repair) 2026-07-22 22:54:19 -04:00
README.md feature: ship per-member XML API docs in Stratum packages 2026-06-14 02:46:20 -04:00
STRATUM.slnx feature: Nexus.Stratum.Browser OPFS backing (compiles + packs; unpublished) 2026-06-13 17:05:59 -04:00

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 tagged stratum).
  • Browser (Blazor WASM, OPFS) durable backing: the separate Nexus.Stratum.Browser package.

The package ships per-member XML API docs (lib/net10.0/Nexus.Stratum.xml): your IDE surfaces them on hover, and an agent can read the public surface with nuget-xml.sh Nexus.Stratum <version> --surface once the package is restored. For the design rationale behind the contracts, read model.fsx and the stratum-tagged CHRONICON events — that is where the "why" lives.

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

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.7"
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 is StoredEvent.mint, so StoredAt/Origin are system-asserted, not forgeable.
  • EventStore<'e> -- Append / ReadAll / Subscribe. Append-only; one store instance is one logical stream. Replay order is StoredAt ascending, ties by EventId. 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 -- InMemory today (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)