.NET idiomatic client libraries for CamusDB
This repository contains two packages:
| Package | Description |
|---|---|
CamusDB.Client |
ADO.NET provider — recommended for direct database access from .NET |
CamusDB.EntityFrameworkCore |
Entity Framework Core provider built on top of CamusDB.Client |
dotnet add package CamusDB.ClientOr via the Package Manager Console:
Install-Package CamusDB.ClientCreate a CamusConnectionStringBuilder with a connection string:
using CamusDB.Client;
CamusConnectionStringBuilder builder = new("Endpoint=http://localhost:8082;Database=test");
await using CamusConnection connection = new(builder);
await connection.OpenAsync();Supported connection string keys:
| Key | Required | Description |
|---|---|---|
Endpoint |
Yes | Base URL for the CamusDB HTTP endpoint. |
Database |
Yes | Database name sent with requests. |
Timeout |
No | HTTP request timeout in seconds (default: 10). |
Endpoint also supports a comma-separated pool. The client selects endpoints with round-robin routing:
CamusConnectionStringBuilder builder = new(
"Endpoint=http://localhost:8082,http://localhost:8084,http://localhost:8086;Database=test");When a request fails because an endpoint is unreachable, that endpoint is marked unavailable and skipped by later requests made through the same CamusConnectionStringBuilder.
CamusDB requires databases to be explicitly created before use. Call CreateDatabaseAsync once during application startup or provisioning:
// Create the database (no-op if it already exists)
await connection.CreateDatabaseAsync(ifNotExists: true);To drop a database:
await connection.DropDatabaseAsync();Both methods operate on the database named in the connection string. An explicit name can also be passed:
await connection.CreateDatabaseAsync("otherdb", ifNotExists: true);
await connection.DropDatabaseAsync("otherdb");await using CamusCommand ping = connection.CreatePingCommand();
int result = await ping.ExecuteNonQueryAsync();await using CamusCommand command = connection.CreateCamusCommand("""
CREATE TABLE robots (
id OID PRIMARY KEY NOT NULL,
name STRING NOT NULL,
type STRING,
year INT64,
price FLOAT64,
enabled BOOL
)
""");
bool created = await command.ExecuteDDLAsync();CamusDB columns are declared with these SQL types; each maps to a ColumnType on the wire and a CLR type the reader/parameters understand:
| SQL DDL type | ColumnType |
CLR type(s) | Notes |
|---|---|---|---|
OID (alias OBJECT_ID) |
Id |
string, Guid, CamusObjectIdValue |
Native identifier; shares string key encoding. |
INT64 (aliases INT, INTEGER) |
Integer64 |
long, int, short, byte |
64-bit signed. |
FLOAT64 |
Float64 |
double |
IEEE-754 double. |
FLOAT32 (alias REAL) |
Float32 |
float |
Stored at single precision. |
BOOL (alias BOOLEAN) |
Bool |
bool |
|
STRING / STRING(N) |
String |
string |
N bounds the length (UTF-16 code units); over-length is rejected. |
DATE |
Date |
DateOnly, DateTime |
Calendar date; stored as UTC ticks at midnight. |
DATETIME (alias TIMESTAMP) |
DateTime |
DateTime, DateTimeOffset |
Instant; normalized to UTC and read back as DateTimeKind.Utc. |
BYTES (alias BLOB) |
Bytes |
byte[] |
base64 over JSON, 0x-hex in SQL literals. Default max 10 MB. |
ARRAY(T) |
Array |
any IEnumerable of T |
Homogeneous scalar list; not indexable, no inline SQL literal. |
await using CamusCommand command = connection.CreateCamusCommand("""
CREATE TABLE events (
id OID PRIMARY KEY NOT NULL,
name STRING(64),
payload BYTES,
score FLOAT32,
happened DATETIME,
day DATE,
tags ARRAY(INT64)
)
""");
await command.ExecuteDDLAsync();Dates/datetimes are normalized to UTC before being sent. Arrays carry a scalar element type, inferred from the values or set explicitly (required for an empty array):
await using CamusCommand insert = connection.CreateInsertCommand("events");
insert.Parameters.Add("id", ColumnType.Id, CamusObjectIdGenerator.Generate());
insert.Parameters.Add("name", ColumnType.String, "launch");
insert.Parameters.Add("payload", ColumnType.Bytes, new byte[] { 0xDE, 0xAD });
insert.Parameters.Add("score", ColumnType.Float32, 9.5f);
insert.Parameters.Add("happened", ColumnType.DateTime, DateTime.UtcNow);
insert.Parameters.Add("day", ColumnType.Date, new DateOnly(2026, 5, 1));
insert.Parameters.Add("tags", ColumnType.Integer64, new long[] { 1, 2, 3 }, isArray: true);
await insert.ExecuteNonQueryAsync();Reading them back uses the typed CamusDataReader accessors:
byte[] payload = reader.GetFieldValue<byte[]>(reader.GetOrdinal("payload"));
float score = reader.GetFloat(reader.GetOrdinal("score"));
DateTime happened = reader.GetDateTime(reader.GetOrdinal("happened"));
DateOnly day = reader.GetFieldValue<DateOnly>(reader.GetOrdinal("day"));
object?[] tags = (object?[])reader.GetValue(reader.GetOrdinal("tags"));using CamusDB.Core.Util.ObjectIds;
await using CamusCommand insert = connection.CreateInsertCommand("robots");
insert.Parameters.Add("id", ColumnType.Id, CamusObjectIdGenerator.Generate());
insert.Parameters.Add("name", ColumnType.String, "T-800");
insert.Parameters.Add("type", ColumnType.String, "cyborg");
insert.Parameters.Add("year", ColumnType.Integer64, 1984);
insert.Parameters.Add("price", ColumnType.Float64, 10.0);
insert.Parameters.Add("enabled", ColumnType.Bool, true);
int insertedRows = await insert.ExecuteNonQueryAsync();You can also execute parameterized SQL:
const string sql = """
INSERT INTO robots (id, name, year, type, price, enabled)
VALUES (GEN_ID(), @name, @year, @type, @price, @enabled)
""";
await using CamusCommand insert = connection.CreateCamusCommand(sql);
insert.Parameters.Add("@name", ColumnType.String, "R2-D2");
insert.Parameters.Add("@year", ColumnType.Integer64, 1977);
insert.Parameters.Add("@type", ColumnType.String, "mechanical");
insert.Parameters.Add("@price", ColumnType.Float64, 25.5);
insert.Parameters.Add("@enabled", ColumnType.Bool, true);
int insertedRows = await insert.ExecuteNonQueryAsync();See Data Types above for inserting bytes, float32, date, datetime and array(T) values.
await using CamusCommand select = connection.CreateSelectCommand(
"SELECT * FROM robots WHERE year = @year");
select.Parameters.Add("@year", ColumnType.Integer64, 1977);
CamusDataReader reader = await select.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
string id = reader.GetString(0);
string name = reader.GetString(1);
string type = reader.GetString(2);
long year = reader.GetInt64(3);
}CamusDB has an opt-in, per-node, in-memory cache of fully materialized SELECT results. A query
joins it with an inline {cache=name} hint placed right after a table reference; an identical later
query (same shape, same bound values, same schema) can then be served from memory. The cache only
serves single-table, autocommit reads — hints on joins or inside explicit transactions are inert.
Add the hint directly in your SQL, optionally with a per-entry TTL or strict (validate every hit
against live storage). CamusCacheHint.Build(...) assembles the fragment for you:
string hint = CamusCacheHint.Build("recent_orders", ttl: TimeSpan.FromSeconds(30));
await using CamusCommand select = connection.CreateSelectCommand(
$"SELECT id, total FROM orders {hint} WHERE status = @status ORDER BY total DESC LIMIT 20");
select.Parameters.Add("@status", ColumnType.Integer64, 1);
CamusDataReader reader = await select.ExecuteReaderAsync();
// Inspect how the server resolved the cache for this query.
CamusCacheMetadata? cache = reader.CacheMetadata; // also on select.LastCacheMetadata
if (cache is not null)
Console.WriteLine($"{cache.Status} ({cache.Name}), age={cache.AgeMs}ms");CamusCacheMetadata exposes Status (Hit, Miss, Bypass, StaleRevalidated,
EvictedBeforePublish), BypassReason, Name, CachedAtHlc, and AgeMs. It is null for any
query that carried no hint, so ordinary queries are unaffected.
Evict entries manually — both are scoped to the current database:
await connection.EvictCacheAsync("recent_orders"); // one family
await connection.EvictAllCacheAsync(); // every entry for this databaseCamusTransaction transaction = await connection.BeginTransactionAsync();
await using CamusCommand insert = connection.CreateInsertCommand("robots");
insert.Transaction = transaction;
insert.Parameters.Add("id", ColumnType.Id, CamusObjectIdGenerator.Generate());
insert.Parameters.Add("name", ColumnType.String, "HAL 9000");
insert.Parameters.Add("type", ColumnType.String, "electronic");
insert.Parameters.Add("year", ColumnType.Integer64, 1968);
insert.Parameters.Add("price", ColumnType.Float64, 42.0);
insert.Parameters.Add("enabled", ColumnType.Bool, true);
await insert.ExecuteNonQueryAsync();
await transaction.CommitAsync();Use await transaction.RollbackAsync() to roll back instead.
Serializable is the default isolation level in CamusDB. When two serializable transactions conflict, one is aborted immediately and must be replayed from BEGIN — retrying a single statement is not safe.
Three error codes indicate a transient conflict that a full retry can resolve:
| Code | Name | When raised |
|---|---|---|
CADB0502 |
TransactionConflict |
Lock conflict; server aborted at lock-acquire time |
CADB0504 |
TransactionMustRetry |
Routing retry budget exhausted; no data written |
CADB0505 |
TransactionLifetimeExceeded |
Transaction held open past the server lifetime limit |
Use SerializableRetryHelper.IsRetryable(ex) to test any exception, and SerializableRetryHelper.ExecuteAutocommitAsync for bounded automatic retry of single-statement (autocommit) operations:
await SerializableRetryHelper.ExecuteAutocommitAsync(async ct =>
{
CamusTransaction tx = await connection.BeginTransactionAsync(ct);
try
{
await using CamusCommand cmd = connection.CreateCamusCommand(
"UPDATE robots SET price = @price WHERE name = @name");
cmd.Transaction = tx;
cmd.Parameters.Add("@price", ColumnType.Float64, 99.0);
cmd.Parameters.Add("@name", ColumnType.String, "T-800");
await cmd.ExecuteNonQueryAsync(ct);
await tx.CommitAsync(ct);
}
catch
{
await tx.RollbackAsync(ct);
throw;
}
}, maxAttempts: 5, cancellationToken);Back-off schedule: min(20 ms × 2^attempt, 400 ms) ± 25 % jitter. Any non-retryable exception propagates immediately.
For explicit multi-statement transactions, own the retry loop yourself so you can replay every read and write from scratch:
const int MaxAttempts = 5;
int attempt = 0;
while (true)
{
CamusTransaction tx = await connection.BeginTransactionAsync();
try
{
// re-execute ALL reads and writes on every attempt
long balance = await ReadBalance(tx, accountId);
if (balance < amount)
throw new InvalidOperationException("Insufficient funds");
await Debit(tx, accountId, balance - amount);
await tx.CommitAsync();
break;
}
catch (CamusException ex) when (SerializableRetryHelper.IsRetryable(ex))
{
await tx.RollbackAsync();
if (++attempt >= MaxAttempts)
throw;
await Task.Delay(20 * (1 << attempt));
}
catch
{
await tx.RollbackAsync();
throw;
}
}dotnet add package CamusDB.EntityFrameworkCoreOr via the Package Manager Console:
Install-Package CamusDB.EntityFrameworkCoreRegister the provider via UseCamusDB in your DbContext options:
using CamusDB.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseCamusDB("Endpoint=http://localhost:8082;Database=mydb;Timeout=30")
.Options;Or configure it inside OnConfiguring:
public class AppDbContext : DbContext
{
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder.UseCamusDB("Endpoint=http://localhost:8082;Database=mydb");
}The same connection string keys are supported as in CamusDB.Client — see the connection string reference above.
Call EnableRetryOnFailure on the CamusDBDbContextOptionsBuilder to let EF Core automatically retry SaveChangesAsync (and query execution) when a transient serialization conflict is detected. Only the three retryable CamusDB error codes (CADB0502, CADB0504, CADB0505) trigger a retry — all other exceptions propagate immediately.
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseCamusDB("Endpoint=http://localhost:8082;Database=mydb", o =>
{
o.EnableRetryOnFailure();
})
.Options;Default parameters:
| Parameter | Default | Description |
|---|---|---|
maxRetryCount |
15 |
Maximum number of retry attempts |
maxRetryDelay |
1 s |
Upper bound on the delay between retries |
retryDeadline |
5 s |
Wall-clock deadline from first failure; no further retries after this |
medianFirstRetryDelay |
30 ms |
Median delay before the first retry |
Override any parameter explicitly:
o.EnableRetryOnFailure(
maxRetryCount: 5,
maxRetryDelay: TimeSpan.FromMilliseconds(500),
retryDeadline: TimeSpan.FromSeconds(3),
medianFirstRetryDelay: TimeSpan.FromMilliseconds(20));EF Core's execution strategy retries the entire unit of work — never only the failing statement. If you manage transactions manually with
BeginTransactionAsync/CommitTransactionAsync, useSerializableRetryHelperinCamusDB.Clientinstead.
Pass an open CamusConnection when you want to share a connection or attach to an externally managed transaction:
CamusConnection connection = await GetOpenConnectionAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseCamusDB(connection)
.Options;
await using var ctx = new AppDbContext(options);
await ctx.Database.BeginTransactionAsync();
// ... SaveChangesAsync, then CommitTransactionAsyncThe DbContext does not take ownership of the supplied connection and will not close or dispose it.
Use standard EF Core data annotations or the fluent API. Map ID columns to the "id" store type and call ValueGeneratedOnAdd() so the provider generates a client-side ObjectId automatically:
public class Robot
{
public string Id { get; set; } = "";
public string Name { get; set; } = "";
public string Type { get; set; } = "";
public int Year { get; set; }
public double Price { get; set; }
public bool Enabled { get; set; }
}
public class AppDbContext : DbContext
{
public DbSet<Robot> Robots => Set<Robot>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Robot>(b =>
{
b.ToTable("robots");
b.HasKey(e => e.Id);
b.Property(e => e.Id)
.HasColumnType("id")
.ValueGeneratedOnAdd(); // client-side ObjectId generation
b.Property(e => e.Name).HasColumnType("string");
b.Property(e => e.Type).HasColumnType("string");
b.Property(e => e.Year).HasColumnType("int64");
b.Property(e => e.Price).HasColumnType("float64");
b.Property(e => e.Enabled).HasColumnType("bool");
});
}
}| CLR type | CamusDB store type | DDL type |
|---|---|---|
string (ID / PK) |
id or oid |
OID |
Guid (ID / PK) |
id or oid |
OID |
string |
string |
STRING |
string + HasMaxLength(n) |
string |
STRING(n) |
bool |
bool |
BOOL |
short, int, long |
int64 |
INT64 |
float |
float32 |
FLOAT32 |
double |
float64 |
FLOAT64 |
byte[] |
bytes (alias blob) |
BYTES |
DateOnly |
date |
DATE |
DateTime, DateTimeOffset |
datetime (alias timestamp) |
DATETIME |
Use HasColumnType("id") (or the alias "oid") for primary key columns backed by CamusDB ObjectIds. The provider sends the value as an OID on the wire regardless of whether the CLR property is string or Guid.
Dates and datetimes are stored as UTC ticks; the provider normalizes DateTime values to UTC before sending and reconstructs them as DateTimeKind.Utc. byte[] is exchanged as base64 over the JSON wire (SQL literals use 0x-hex). A string property maps to float32/bytes/date/datetime etc. either by its CLR type or by an explicit HasColumnType(...). Arrays (array(T)) are not indexable and have no inline SQL literal — they are written through the ADO.NET parameter path (see below); the EF Core provider does not map array columns.
EnsureCreatedAsync() creates the database and all tables defined in the model. Both operations are idempotent — it is safe to call on a database or tables that already exist:
await using var ctx = new AppDbContext();
await ctx.Database.EnsureCreatedAsync();EnsureDeletedAsync() drops the database entirely:
await ctx.Database.EnsureDeletedAsync();await using var ctx = new AppDbContext();
ctx.Robots.Add(new Robot
{
Name = "T-800",
Type = "cyborg",
Year = 1984,
Price = 10.0,
Enabled = true
});
await ctx.SaveChangesAsync(); // Id is generated automaticallyawait using var ctx = new AppDbContext();
// Key lookup
Robot? robot = await ctx.Robots.FindAsync(id);
// LINQ predicate
List<Robot> active = await ctx.Robots
.Where(r => r.Enabled && r.Year > 1980)
.ToListAsync();Opt a LINQ query into CamusDB's query result cache with WithCache(name).
The provider injects the {cache=…} hint into the generated SQL, so an identical later query is
served from the server's in-memory cache. As with the raw client, only single-table, autocommit
reads are cacheable — a query with a join, or one inside an explicit transaction, reads live storage.
using CamusDB.EntityFrameworkCore;
// Cache with the server's default TTL
List<Order> recent = await ctx.Orders
.Where(o => o.Status == 1)
.OrderByDescending(o => o.Total)
.Take(20)
.WithCache("recent_orders")
.ToListAsync();
// Per-entry TTL override and strict per-hit validation
List<Order> hot = await ctx.Orders
.Where(o => o.Status == 1)
.WithCache("hot_orders", ttl: TimeSpan.FromSeconds(30), strict: true)
.ToListAsync();Evict entries through the underlying CamusConnection (EvictCacheAsync / EvictAllCacheAsync).
await using var ctx = new AppDbContext();
Robot robot = await ctx.Robots.FindAsync(id)
?? throw new InvalidOperationException("Not found");
robot.Price = 99.0;
await ctx.SaveChangesAsync();await using var ctx = new AppDbContext();
Robot robot = await ctx.Robots.FindAsync(id)
?? throw new InvalidOperationException("Not found");
ctx.Robots.Remove(robot);
await ctx.SaveChangesAsync();The provider supports EF Core migrations for the following DDL operations:
| Operation | Generated SQL |
|---|---|
| Create table | CREATE TABLE t (col TYPE [NOT NULL], ..., PRIMARY KEY (col1, ...)) |
| Drop table | DROP TABLE t |
| Rename table | ALTER TABLE t RENAME TO new_name |
| Add column | ALTER TABLE t ADD COLUMN col TYPE [NOT NULL] [DEFAULT (value)] |
| Drop column | ALTER TABLE t DROP COLUMN col |
| Rename column | ALTER TABLE t RENAME COLUMN old TO new |
| Create index | CREATE INDEX name ON t (col1, col2) |
| Create unique index | CREATE UNIQUE INDEX name ON t (col1, col2) |
| Drop index | ALTER TABLE t DROP INDEX name |
| Rename index | ALTER TABLE t RENAME INDEX old TO new |
| Raw SQL | passed through as-is |
The provider ships design-time services so the EF tooling can discover the provider automatically. No extra flags are needed:
dotnet ef migrations add InitialCreate
dotnet ef database updateExample migration using the supported operations:
public partial class AddStockColumn : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "Stock",
table: "products",
type: "int64",
nullable: false,
defaultValue: 0);
migrationBuilder.CreateIndex(
name: "idx_products_name",
table: "products",
column: "Name",
unique: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(name: "idx_products_name", table: "products");
migrationBuilder.DropColumn(name: "Stock", table: "products");
}
}[ConcurrencyCheck] is supported on numeric columns (short, int, long). The application is responsible for incrementing the version column before calling SaveChanges() — CamusDB has no server-side auto-increment version type:
public class Order
{
public string Id { get; set; } = "";
public string Status { get; set; } = "";
[ConcurrencyCheck]
public long Version { get; set; }
}
// On update: increment Version manually so EF adds AND Version = @original_version to the WHERE
order.Status = "shipped";
order.Version++;
await ctx.SaveChangesAsync();[Timestamp] (byte array row version) is not supported.
Note on MVCC concurrency: CamusDB uses multi-version concurrency control (MVCC). Write-write conflicts between transactions are detected at commit time, not during the write phase. This means
SaveChangesAsyncwill succeed even when two open transactions have both updated the same row — the conflict surfaces when the second transaction callsCommitTransactionAsync. Application-level optimistic concurrency via[ConcurrencyCheck](above) is the recommended pattern for detecting stale updates.
- No computed columns.
- No foreign key constraints.
- No
ALTER COLUMN— changing a column type requires dropping and recreating the column. array(T)columns are not mapped by the EF Core provider (arrays are not indexable and have no SQL literal). Use the ADO.NET parameter path for array values.- Key CLR types must be one of:
string,int,long,short, orGuid. [ConcurrencyCheck]is only supported onshort,int, andlongcolumns;[Timestamp]is not supported.- MVCC conflict detection occurs at commit time, not during
SaveChangesAsync. Use application-level version columns with[ConcurrencyCheck]for optimistic concurrency. WithCache(...)only takes effect on single-table, autocommit reads; the hint is inert on queries with a join or run inside an explicit transaction (they read live storage).
To run the unit tests, a CamusDB instance must be running locally. After starting it, run:
dotnet test -l "console;verbosity=normal" --filter "FullyQualifiedName~CamusDB.Client.Tests"CamusDB.Client is an open-source project, and contributions are heartily welcomed! Whether you are looking to fix bugs, add new features, or improve documentation, your efforts and contributions will be appreciated. Check out the CONTRIBUTING.md file for guidelines on how to get started with contributing to CamusDB.Client.
CamusDB.Client is released under the MIT License.