Marten / PostgreSQL
Ranvier.Persistence.Marten stores persisted inputs in PostgreSQL through Marten. It targets .NET 10 and keeps the database client in a separate adapter package. See Persisted inputs for keys, codecs and migrations.
Setup
Install the .NET 10 adapter from NuGet:
dotnet add package Ranvier.Persistence.Marten --version 0.1.1-preview.1
open Marten
open Ranvier.Persistence
open Ranvier.Persistence.Marten
use database = DocumentStore.For connectionString
let store = MartenStateStore(database, "customer-42") :> IStateStore
Keep database for the application's lifetime. Each adapter operation opens and disposes its own session. The scope is a Marten tenant: two scopes can use "count" independently, and Clear removes only the chosen scope's records. Use a stable scope for the state you want to reload.
The document's mapping enables tenant isolation and version metadata automatically. Marten's configured schema-management policy controls table creation; provision the mapping before using a database that disables automatic schema changes.
Save, reload, derive
This example saves one input, reloads it and recomputes a memo on a fresh graph:
open Ranvier
let codec: JournalCodec<int> =
{ Name = "int"
Version = 1
Encode = string
Decode = fun text ->
match System.Int32.TryParse text with
| true, value -> Ok value
| _ -> Error "Expected an integer" }
let countKey = Persist.key "count" (PersistCodec.ofJournal codec)
let writeCount session value =
use graph = new Graph()
use active = graph.Activate()
let count = Persist.signal session countKey 0
count.Value <- value
session.FlushAsync()
let readCount session =
use graph = new Graph()
use active = graph.Activate()
let count = Persist.signal session countKey 0
let doubled = createMemo (fun _ -> count.Value * 2)
count.Value, doubled.Value
let roundTrip () =
task {
let! session = Persist.loadAsync store [ countKey ]
do! writeCount session 21
let! restored = Persist.loadAsync store [ countKey ]
return readCount restored
}
// roundTrip() completes with (21, 42).
writeCount builds and changes the graph synchronously. Its returned task acknowledges the already queued save, which continues after the graph is disposed. Await that task before reloading.
Conditional writes
Ordinary PersistSession writes currently carry ETag = None, so they use last-writer-wins. For an explicit conditional update, keep the ETag returned by LoadMany:
let incrementStored () =
task {
let! rows = store.LoadMany [ "count" ]
let current = rows.Head
let next = { current with Payload = string (int current.Payload + 1) }
do! store.SaveMany [ next ]
return! store.LoadMany [ "count" ]
}
Seed "count" with the round-trip example first. If another writer changes or deletes that record before the save commits, the conditional save faults. The batch rolls back, including unconditional records in it. Marten reports JasperFx.ConcurrencyException, which may be wrapped in an AggregateException. Reload and resolve the conflict in the application; the adapter does not retry a stale update.
A successful save changes the ETag. Reload before the next conditional save. StoredRecord.Version is the codec version and changes only when your payload format changes; the ETag tracks database writes.
Storage behaviour
- One
SaveManyis one PostgreSQL transaction. - A record with
ETag = Noneinserts or replaces its key.Some tagrequires an existing matching version. DeleteManyandClearare unconditional and scoped to the tenant.- A save containing duplicate keys or a malformed ETag raises
ArgumentExceptionbefore committing. - Keys are case-sensitive Unicode strings. The store also accepts an empty key, although
Persist.keyrejects it.
This adapter implements state storage. A remote journal transport and a combined state/journal transaction are separate work under #185.
Run the integration suite
Point RANVIER_MARTEN_CONNECTION at a disposable PostgreSQL database, then run:
dotnet test tests/Ranvier.Persistence.Marten.Tests -c Release
The suite includes the shared store and ETag cases, competing writers, rollback, tenant isolation and graph restart. It fails when the connection is missing or the database is unavailable.