Scriva.Client 1.2.1

dotnet add package Scriva.Client --version 1.2.1
                    
NuGet\Install-Package Scriva.Client -Version 1.2.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Scriva.Client" Version="1.2.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Scriva.Client" Version="1.2.1" />
                    
Directory.Packages.props
<PackageReference Include="Scriva.Client" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Scriva.Client --version 1.2.1
                    
#r "nuget: Scriva.Client, 1.2.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Scriva.Client@1.2.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Scriva.Client&version=1.2.1
                    
Install as a Cake Addin
#tool nuget:?package=Scriva.Client&version=1.2.1
                    
Install as a Cake Tool

ScrivaDB — C# / .NET SDK

.NET 8 gRPC client for ScrivaDB.

NuGet package: Scriva.Client (version 0.1.0)


Requirements

  • .NET 8 SDK or later
  • A running ScrivaDB server (make run from the repo root, or Docker)

Build

cd clients/csharp
dotnet build

Proto stubs are generated automatically by Grpc.Tools during dotnet build. No manual protoc invocation is needed.


Install

Note: Replace 0.1.0 with the published version once released to NuGet.

Package reference (.csproj)

<PackageReference Include="Scriva.Client" Version="0.1.0" />

.NET CLI

dotnet add package Scriva.Client --version 0.1.0

Quick start

using Scriva.Client;

await using var db = new ScrivaDB("localhost", 5433, "dev-key");

// Collection management
await db.CreateCollectionAsync("users");

// Insert a record — returns the assigned ulong id
ulong id = await db.InsertAsync("users", new()
{
    ["name"] = "Alice",
    ["age"]  = 30,
    ["role"] = "admin",
});

// Fetch by id — returns a Record (Id, Key, Rev, Data, timestamps)
Record record = await db.FindByIdAsync("users", id);
Console.WriteLine(record["name"]);       // Alice  (shortcut for record.Data["name"])
Console.WriteLine(record.Rev);           // 1

// Streaming find — use `await foreach`
await foreach (var r in db.FindAsync("users",
    filter:  new() { ["field"] = "role", ["op"] = "eq", ["value"] = "admin" },
    orderBy: new[] { Order.Ascending("name") }))
{
    Console.WriteLine(r["name"]);
}

// Or collect all results at once
var admins = await db.FindAllAsync("users",
    filter: new() { ["field"] = "role", ["op"] = "eq", ["value"] = "admin" });

// Update
await db.UpdateAsync("users", id, new() { ["name"] = "Alice", ["age"] = 31 });

// Delete
await db.DeleteAsync("users", id);

// Stats
CollectionStats stats = await db.StatsAsync("users");
Console.WriteLine(stats); // users: records=0 segments=1 dirty=1 size=...B

// Drop
await db.DropCollectionAsync("users");

ScrivaDB implements IAsyncDisposable (await using) and IDisposable (using).


API reference

Constructor

// Plaintext (no TLS)
var db = new ScrivaDB(string host, int port, string apiKey);

// TLS — verifies server against a PEM-encoded CA certificate
var db = new ScrivaDB(string host, int port, string apiKey, string tlsCaCertPath);

x-api-key is attached as gRPC metadata on every call automatically.


Collection management

string             name  = await db.CreateCollectionAsync("col");
bool               ok    = await db.DropCollectionAsync("col");
IReadOnlyList<string> ns = await db.ListCollectionsAsync();

// Give the collection a default per-record TTL (seconds). Records inserted
// without an explicit TTL then expire this long after being written.
await db.CreateCollectionAsync("sessions", defaultTtlSeconds: 3600);

CRUD

// Insert one record — returns assigned id
ulong id = await db.InsertAsync("col", new() { ["field"] = "value" });

// Insert multiple records — returns ids in insertion order
IReadOnlyList<ulong> ids = await db.InsertManyAsync("col", new[]
{
    new Dictionary<string, object?> { ["name"] = "Alice" },
    new Dictionary<string, object?> { ["name"] = "Bob" },
});

// Find by id — returns a Record
Record record = await db.FindByIdAsync("col", id);

// Streaming find (IAsyncEnumerable<Record>) — multi-field ordering, projection, paging
await foreach (var r in db.FindAsync("col",
    filter:    filter,                          // Dictionary<string,object?>? — null = no filter
    limit:     0,                               // uint — 0 = no limit
    offset:    0,                               // uint
    orderBy:   new[] { Order.Ascending("name") }, // IEnumerable<Order>? — null = no ordering
    fields:    null,                            // IEnumerable<string>? — project data fields (N2)
    pageToken: ""))                             // string — keyset cursor (N3)
{
    Console.WriteLine(r["name"]);
}

// Collect all results
List<Record> all = await db.FindAllAsync("col", filter: filter);

// Convenience — all records, no filter
await foreach (var r in db.FindAsync("col")) { ... }

// Update — returns updated id
ulong updatedId = await db.UpdateAsync("col", id, new() { ["name"] = "new value" });

// Delete — returns true if record existed
bool deleted = await db.DeleteAsync("col", id);

Every read returns a Record:

ulong                       id     = record.Id;            // server-assigned numeric id
string                      key    = record.Key;           // caller-supplied key ("" if keyless)
ulong                       rev    = record.Rev;           // per-record revision (starts at 1)
Dictionary<string, object?> data   = record.Data;          // decoded document
DateTimeOffset?             added  = record.DateAdded;      // creation timestamp
object?                     name   = record["name"];        // shortcut for record.Data["name"]
bool                        keyed  = record.HasKey;
Per-record TTL

InsertAsync, InsertManyAsync, and UpdateAsync each take an optional ttlSeconds (seconds):

// Expire this record 60 seconds from now, regardless of the collection default.
await db.InsertAsync("sessions", new() { ["token"] = "abc" }, ttlSeconds: 60);

// Same TTL applied to every record in the batch.
await db.InsertManyAsync("sessions", new[]
{
    new Dictionary<string, object?> { ["token"] = "a" },
    new Dictionary<string, object?> { ["token"] = "b" },
}, ttlSeconds: 60);

// On update, ttlSeconds > 0 resets the deadline; ttlSeconds 0 (the default) is
// sticky and leaves the existing deadline untouched.
await db.UpdateAsync("sessions", id, new() { ["token"] = "abc", ["seen"] = true }, ttlSeconds: 120);

ttlSeconds of 0 (the default) inherits the collection's default TTL on insert; a value greater than 0 overrides it. Negative values are rejected by the server.


Keyed CRUD, upsert & compare-and-swap (N1)

Records may carry a caller-supplied string key in addition to their numeric id. Keyed operations map straight onto the engine's keyed API, giving natural primary keys, upsert, and optimistic-concurrency (revision) updates.

// Keyed insert — insert under a caller-supplied key.
// Raises AlreadyExistsException if the key is already held by a live record.
ulong id = await db.InsertKeyedAsync("users", "user:alice", new() { ["name"] = "Alice" });
// (equivalently: db.InsertAsync("users", data, key: "user:alice"))

// Fetch by key — raises NotFoundException if no live record carries the key.
Record r = await db.FindByKeyAsync("users", "user:alice");
Console.WriteLine(r.Rev); // per-record revision

// Upsert — insert under the key, or replace the existing record's data
// (bumping its rev), atomically. Returns the resulting Record.
Record up = await db.UpsertAsync("users", "user:alice", new() { ["name"] = "Alice", ["age"] = 31 });

// Update by key — overwrite the keyed record, preserving the key.
// Returns an UpdateResult (Id, Key, Rev, DateModified).
UpdateResult res = await db.UpdateByKeyAsync("users", "user:alice", new() { ["name"] = "Alice A." });

// Compare-and-swap — apply the write only if the current rev matches.
// A stale rev (or missing key) is a clean no-op (Swapped == false), never an error.
CasResult cas = await db.UpdateIfRevAsync("users", "user:alice", expectedRev: res.Rev,
    new() { ["name"] = "Alice", ["age"] = 32 });
if (cas.Swapped) Console.WriteLine(cas.Record!.Rev);

// Delete by key — raises NotFoundException if no live record carries the key.
bool ok = await db.DeleteByKeyAsync("users", "user:alice");
Typed exceptions

Keyed operations map engine gRPC status codes onto typed exceptions (both derive from ScrivaDBException):

Exception gRPC code Raised by
NotFoundException NOT_FOUND FindByKeyAsync, UpdateByKeyAsync, DeleteByKeyAsync
AlreadyExistsException ALREADY_EXISTS InsertKeyedAsync / InsertAsync with a key

Any other status code propagates unchanged as the original Grpc.Core.RpcException.


Field projection (N2)

FindByIdAsync, FindByKeyAsync, and FindAsync/FindAllAsync/FindPageAsync take an optional fields list. When non-empty, only those top-level data fields are returned (id, key, and rev are always included); an unknown field is silently omitted.

Record r = await db.FindByIdAsync("users", id, fields: new[] { "name", "email" });

await foreach (var rec in db.FindAsync("users", fields: new[] { "name" })) { ... }

Ordering & keyset pagination (N3)

Ordering is a list of Order sort keys, applied in order (multi-field sort). The record id is always the final tiebreaker, so the sort is total and pagination is stable.

var order = new[] { Order.Ascending("role"), Order.Descending("age") };

FindPageAsync returns one keyset Page — the records plus a next-page cursor. Feed page.NextPageToken back as pageToken to walk the collection in O(page) time; an empty token means the last page was reached. Keep the same filter, ordering, and limit on every page.

string token = "";
do
{
    Page page = await db.FindPageAsync("users", limit: 50, orderBy: order, pageToken: token);
    foreach (var r in page.Records) Console.WriteLine(r["name"]);
    token = page.NextPageToken;
} while (!string.IsNullOrEmpty(token));

The deprecated single-field sort remains available via the legacy overload FindAsync(collection, filter, limit, offset, orderBy: "name", descending: false).


Aggregations (N4)

Aggregations run entirely in the engine over the same Filter as Find; the collection is never materialised on the client.

// Count — whole collection, or filtered.
ulong total = await db.CountAsync("users");
ulong admins = await db.CountAsync("users",
    new() { ["field"] = "role", ["op"] = "eq", ["value"] = "admin" });

// Aggregate — count + numeric sum/avg/min/max over a field (single whole-set group).
List<AggResult> overall = await db.AggregateAsync("orders",
    aggregations: new[] { "sum", "avg", "min", "max" }, field: "total");
Console.WriteLine(overall[0].Sum);

// GroupBy — one AggResult per distinct value of the group-by field.
List<AggResult> byDept = await db.GroupByAsync("employees",
    field: "dept", aggregations: new[] { "count", "avg" }, metric: "salary");
foreach (var g in byDept)
    Console.WriteLine($"{g.Group}: count={g.Count} avg={g.Avg}");

Each AggResult carries Group (the group-by value, null for the whole-set group), Count, and — when at least one record in the group held a numeric field value (Numeric == true) — Sum, Avg, Min, Max. Supported aggregation names: count, sum, avg, min, max (count is always returned; the rest require field).


Secondary indexes

await db.EnsureIndexAsync("col", "fieldName");
bool               ok     = await db.DropIndexAsync("col", "fieldName");
IReadOnlyList<string> flds = await db.ListIndexesAsync("col");

Transactions

string txId = await db.BeginTxAsync("col");
bool committed  = await db.CommitTxAsync(txId);
bool rolledBack = await db.RollbackTxAsync(txId);

Watch (streaming change feed)

using var cts = new CancellationTokenSource();

await foreach (var evt in db.WatchAsync("col", ct: cts.Token))
{
    Console.WriteLine($"{evt.Op} id={evt.RecordId} data={evt.Record["name"]}");
    // evt.Op          — "Inserted" | "Updated" | "Deleted" | "Overflow"
    //                   ("Overflow" = server dropped events; resync needed)
    // evt.Collection  — collection name
    // evt.RecordId    — ulong record id
    // evt.Record      — Dictionary<string, object?> record data
    // evt.Timestamp   — DateTimeOffset
}

// Cancel to stop the stream
cts.Cancel();

With an optional filter (only matching events are delivered):

await foreach (var evt in db.WatchAsync("col",
    filter: new() { ["field"] = "role", ["op"] = "eq", ["value"] = "admin" },
    ct: cts.Token))
{ ... }

Stats

CollectionStats stats = await db.StatsAsync("col");
// stats.Collection    string
// stats.RecordCount   ulong
// stats.SegmentCount  ulong
// stats.DirtyEntries  ulong
// stats.SizeBytes     ulong

Maintenance

// Force a synchronous compaction of a collection — merges dirty segments and
// reclaims space from deleted/overwritten records. Returns true on success.
await db.CompactAsync("users");

// Stream a consistent gzip-compressed tar snapshot of the whole database
// straight to a file. Returns the number of bytes written; restore with
// `tar xzf backup.tar.gz`.
long bytes = await db.SnapshotToFileAsync("backup.tar.gz");

// Or consume the raw archive chunks yourself (Snapshot is server-streaming):
await foreach (ReadOnlyMemory<byte> chunk in db.SnapshotAsync())
{
    // await outStream.WriteAsync(chunk);
}

Filter syntax

Filters are Dictionary<string, object?> values that mirror the proto Filter message.

Field filter

new() { ["field"] = "age",  ["op"] = "gt",       ["value"] = "30" }
new() { ["field"] = "name", ["op"] = "contains",  ["value"] = "alice" }
new() { ["field"] = "bio",  ["op"] = "regex",     ["value"] = "engineer|developer" }

AND composite

new()
{
    ["and"] = new List<Dictionary<string, object?>>
    {
        new() { ["field"] = "age",  ["op"] = "gte", ["value"] = "18" },
        new() { ["field"] = "role", ["op"] = "eq",  ["value"] = "admin" },
    },
}

OR composite

new()
{
    ["or"] = new List<Dictionary<string, object?>>
    {
        new() { ["field"] = "status", ["op"] = "eq", ["value"] = "active" },
        new() { ["field"] = "role",   ["op"] = "eq", ["value"] = "admin" },
    },
}

Supported op values

op Meaning
eq equal
neq not equal
gt greater than
gte greater than or equal
lt less than
lte less than or equal
contains string contains (substring)
regex regular expression match

TLS

var db = new ScrivaDB("myserver.example.com", 5433, "my-api-key", "/path/to/ca.crt");

Pass the path to a PEM-encoded CA certificate. The client verifies the server certificate against this CA. Without a CA cert path the client connects over plaintext (no TLS).


Running the example

# 1. Start the server (from repo root)
make run

# 2. In another terminal
cd clients/csharp
dotnet run --project Scriva.Example

Override connection settings with environment variables:

SCRIVA_HOST=myserver.example.com SCRIVA_PORT=5433 SCRIVA_API_KEY=my-key \
  dotnet run --project Scriva.Example

NuGet publish

cd clients/csharp
dotnet pack Scriva.Client/Scriva.Client.csproj -c Release -o dist/
dotnet nuget push dist/Scriva.Client.0.1.0.nupkg --api-key $NUGET_API_KEY \
  --source https://api.nuget.org/v3/index.json
Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.2.1 115 7/23/2026