Skip to main content

.NET Driver

CamusDB ships an ADO.NET provider for direct access from .NET applications. The package name is CamusDB.Client.

It targets net8.0, net9.0, and net10.0.

Install

dotnet add package CamusDB.Client

Connection String

Create a CamusConnection with a connection string containing:

  • Endpoint: the base CamusDB endpoint
  • Database: the database name to use
using CamusDB.Client;

CamusConnectionStringBuilder builder =
new("Endpoint=http://localhost:5095;Database=test");

await using CamusConnection connection = new(builder);
await connection.OpenAsync();

Supported keys:

KeyRequiredDescription
EndpointYesBase URL for the CamusDB node. Use the REST port by default, or the gRPC port when Protocol=grpc.
DatabaseYesDatabase name sent on requests.
TimeoutNoRequest timeout in seconds. Defaults to 10.
ProtocolNoWire protocol: rest by default, or grpc.
UserNoUser to authenticate as. Aliases: UserId, Uid, Username.
PasswordNoPassword for User. Alias: Pwd.
AccessTokenNoBearer token obtained elsewhere. Used directly instead of logging in.
TokenLifetimeNoFallback seconds to reuse a token if the server does not report expiry. Defaults to 600.
MaxAutoPrepareNoMaximum statements the driver keeps prepared. Defaults to 128; 0 disables automatic preparation.
AutoPrepareMinUsagesNoExecutions of the same SQL before the driver prepares it. Defaults to 2.

Endpoint can also be a comma-separated pool:

Endpoint=http://localhost:5095,http://localhost:5096,http://localhost:5097;Database=test

The client uses round-robin routing across endpoints. If one endpoint becomes unreachable, it is marked unhealthy and skipped by later requests that use the same connection-string builder.

Authentication

CamusDB authentication is off by default. A connection string without credentials sends no Authorization header. Against a server with authentication enabled, add User and Password:

CamusConnectionStringBuilder builder = new(
"Endpoint=https://db.example.com:7141;Database=app;User=myapp;Password=app-secret");

The driver exchanges the password once for a short-lived bearer token, using REST /login on REST connections and the CamusAuth service on gRPC connections. Statements then send the token, not the password. The token is cached for the credential set and renewed from the expiry reported by the server.

You can also log in explicitly when the password comes from a secret manager:

await using CamusConnection connection = new(
new CamusConnectionStringBuilder("Endpoint=https://db.example.com:7141;Database=app"));

await connection.OpenAsync();
string token = await connection.LoginAsync("myapp", passwordFromSecretManager);
await connection.LogoutAsync();

AccessToken=... uses a token obtained elsewhere and does not renew it. If that token expires or is revoked, the server returns CADB0516.

Use https:// for non-loopback authenticated deployments. See Authentication And Authorization for server setup, grants, and TLS behavior.

Open A Connection

await using CamusConnection connection =
new(new CamusConnectionStringBuilder(
"Endpoint=http://localhost:5095;Database=test"));

await connection.OpenAsync();

ChangeDatabase("otherdb") updates the target database on the connection.

Opening a connection does not create the database. Create databases explicitly before running table DDL, DML, or queries.

await connection.CreateDatabaseAsync(ifNotExists: true);
await connection.CreateDatabaseAsync("otherdb", ifNotExists: true);

await connection.DropDatabaseAsync("old_test_db");

CreateDatabaseAsync() and DropDatabaseAsync() operate on the database in the connection string unless you pass an explicit name. Database creation retries a small set of transient schema-allocation conflicts internally.

For copy-on-write database branches, use the branching helpers:

await connection.CreateBranchDatabaseAsync(
branchName: "factory_test",
sourceDatabaseName: "factory",
ifNotExists: true);

IReadOnlyList<CamusBranchRow> branches =
await connection.ShowBranchesAsync("factory");

IReadOnlyList<CamusBranchRow> ancestors =
await connection.ShowAncestorsAsync("factory_test");

See Database Branching for the SQL behavior behind these helpers.

Ping

Use a ping command to verify connectivity:

await using CamusCommand ping = connection.CreatePingCommand();
int result = await ping.ExecuteNonQueryAsync();

Run DDL

Use CreateCamusCommand(...) for SQL statements:

await using CamusCommand ddl = connection.CreateCamusCommand("""
CREATE TABLE robots (
id OID PRIMARY KEY NOT NULL,
name STRING NOT NULL,
kind STRING,
year INT64,
price FLOAT64,
enabled BOOL
)
""");

bool created = await ddl.ExecuteDDLAsync();

ExecuteDDLAsync() is also the direct path for CamusDB-specific DDL such as CHECK constraints, named NOT NULL, index operations, table renames, and raw schema changes not wrapped by a helper method.

Insert Rows

For inserts, you can either use the insert helper or parameterized SQL.

Insert helper

using CamusDB.Core.Util.ObjectIds;

await using CamusInsertCommand insert = connection.CreateInsertCommand("robots");

insert.Parameters.Add("id", ColumnType.Id, CamusObjectIdGenerator.Generate());
insert.Parameters.Add("name", ColumnType.String, "T-800");
insert.Parameters.Add("kind", 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 inserted = await insert.ExecuteNonQueryAsync();

Parameterized SQL

await using CamusCommand insert = connection.CreateCamusCommand("""
INSERT INTO robots (id, name, year, kind, price, enabled)
VALUES (GEN_ID(), @name, @year, @kind, @price, @enabled)
""");

insert.Parameters.Add("@name", ColumnType.String, "R2-D2");
insert.Parameters.Add("@year", ColumnType.Integer64, 1977);
insert.Parameters.Add("@kind", ColumnType.String, "mechanical");
insert.Parameters.Add("@price", ColumnType.Float64, 25.5);
insert.Parameters.Add("@enabled", ColumnType.Bool, true);

int inserted = await insert.ExecuteNonQueryAsync();

Query Rows

Use ExecuteReaderAsync() to stream result rows:

await using CamusCommand select = connection.CreateSelectCommand(
"SELECT id, name, year FROM robots WHERE year = @year");

select.Parameters.Add("@year", ColumnType.Integer64, 1977);

await using CamusDataReader reader = await select.ExecuteReaderAsync();

while (await reader.ReadAsync())
{
string id = reader.GetString(0);
string name = reader.GetString(1);
long year = reader.GetInt64(2);
}

The reader exposes standard typed getters such as:

  • GetString
  • GetBoolean
  • GetInt16 / GetInt32 / GetInt64
  • GetFloat / GetDouble
  • GetGuid
  • GetDateTime
  • GetFieldValue<T> for DateOnly, DateTimeOffset, byte[], float, Guid, and other provider-supported values
  • IsDBNull

Parameters

Parameters are input-only. Supported value mappings include:

Camus typeTypical .NET values
ColumnType.Idstring, Guid, CamusObjectIdValue
ColumnType.UuidGuid, canonical UUID string
ColumnType.Stringstring
ColumnType.Integer64short, int, long, other integer-convertible values
ColumnType.Float64double, decimal, other floating-convertible values
ColumnType.Float32float, other floating-convertible values
ColumnType.Boolbool
ColumnType.Bytesbyte[], ReadOnlyMemory<byte>, Memory<byte>, ArraySegment<byte>, IEnumerable<byte>
ColumnType.DateDateOnly, DateTime, DateTimeOffset, ISO date/time string
ColumnType.DateTimeDateTime, DateTimeOffset, ISO date/time string
ColumnType.ArrayIEnumerable of a scalar supported type
ColumnType.Nullnull, DBNull.Value

Examples:

command.Parameters.Add("@id", ColumnType.Id, Guid.NewGuid());
command.Parameters.Add("@ref", ColumnType.Uuid, Guid.NewGuid());
command.Parameters.Add("@count", ColumnType.Integer64, 5);
command.Parameters.Add("@price", ColumnType.Float64, 19.99);
command.Parameters.Add("@payload", ColumnType.Bytes, new byte[] { 0xDE, 0xAD });
command.Parameters.Add("@day", ColumnType.Date, new DateOnly(2026, 5, 1));
command.Parameters.Add("@happened", ColumnType.DateTime, DateTimeOffset.UtcNow);
command.Parameters.Add("@note", ColumnType.Null, null);

For arrays, pass isArray: true. Set the scalar element type explicitly for empty arrays or arrays where all current values are NULL.

command.Parameters.Add(
"@tags",
ColumnType.Integer64,
new long[] { 1, 2, 3 },
isArray: true);

command.Parameters.Add(
"@empty_tags",
ColumnType.String,
Array.Empty<string>(),
isArray: true);

Dates and datetimes are normalized to UTC before they are sent. DATE values are stored at midnight UTC. DATETIME values are read back with DateTimeKind.Utc.

Prepared Statements

The driver prepares repeated SQL statements automatically. Once the same SQL shape has been executed enough times, later executions run as prepared statements without changing application code.

for (int i = 0; i < 100; i++)
{
await using CamusCommand select = connection.CreateSelectCommand(
"SELECT name FROM robots WHERE year = @year");

select.Parameters.Add("@year", ColumnType.Integer64, 1984);

await using CamusDataReader reader = await select.ExecuteReaderAsync();
while (await reader.ReadAsync())
Console.WriteLine(reader.GetString(0));
}

By default, the first execution runs inline and the second execution prepares the statement. Tune the policy in the connection string:

CamusConnectionStringBuilder eager = new(
"Endpoint=http://localhost:5095;Database=test;MaxAutoPrepare=512;AutoPrepareMinUsages=1");

CamusConnectionStringBuilder off = new(
"Endpoint=http://localhost:5095;Database=test;MaxAutoPrepare=0");

Call Prepare() or PrepareAsync() when you already know a statement is hot:

await using CamusCommand insert = connection.CreateCamusCommand("""
INSERT INTO robots (id, name, year)
VALUES (GEN_ID(), @name, @year)
""");

await insert.PrepareAsync();

foreach (Robot robot in robots)
{
insert.Parameters.Clear();
insert.Parameters.Add("@name", ColumnType.String, robot.Name);
insert.Parameters.Add("@year", ColumnType.Integer64, robot.Year);

await insert.ExecuteNonQueryAsync();
}

Prepared execution preserves the same transaction, isolation, locking, affected-row, and query-result-cache behavior as inline execution. Parameters are still bound by name in the ADO.NET API; the driver maps them to the server's positional binding order.

Only SELECT, INSERT, UPDATE, DELETE, and SHOW statements are preparable. If a statement cannot be prepared, the driver runs it inline. Unknown server handles are handled transparently by preparing again and replaying once.

CamusConnectionStringBuilder.PreparedStatementCount and IsPrepared(sql) are available for diagnostics.

See Prepared Statements for the server-side handle scope, REST/gRPC lifecycle, and limits.

Data Types

The ADO.NET driver covers CamusDB's current scalar and array type surface:

SQL DDL typeDriver typeTypical read/write type
OID, OBJECT_IDColumnType.Idstring, Guid, CamusObjectIdValue
UUID, GUIDColumnType.UuidGuid
STRING, STRING(N)ColumnType.Stringstring
INT64, INT, INTEGERColumnType.Integer64long, int, short
FLOAT64ColumnType.Float64double
FLOAT32, REALColumnType.Float32float
BOOL, BOOLEANColumnType.Boolbool
BYTES, BLOBColumnType.Bytesbyte[]
DATEColumnType.DateDateOnly, DateTime
DATETIME, TIMESTAMPColumnType.DateTimeDateTime, DateTimeOffset
ARRAY(T)ColumnType.Arrayobject?[] on read, IEnumerable on write

Use native UUID columns for UUID values instead of storing UUID text in STRING; the native type is more efficient on memory and disk and compares as a fixed-width value.

Query Result Cache

CamusDB's query result cache is available from raw SQL. Put a {cache=...} hint after the table reference, or build the hint with CamusCacheHint.

string hint = CamusCacheHint.Build(
"recent_orders",
ttl: TimeSpan.FromSeconds(30),
strict: true);

await using CamusCommand select = connection.CreateSelectCommand(
$"SELECT id, total FROM orders {hint} WHERE status = @status");

select.Parameters.Add("@status", ColumnType.Integer64, 1);

await using CamusDataReader reader = await select.ExecuteReaderAsync();

CamusCacheMetadata? cache = reader.CacheMetadata;
CamusCacheMetadata? lastCache = select.LastCacheMetadata;

CamusCacheMetadata reports the server cache decision, including statuses such as Hit, Miss, Bypass, StaleRevalidated, and EvictedBeforePublish.

Evict cache families through the connection:

await connection.EvictCacheAsync("recent_orders");
await connection.EvictAllCacheAsync();

Cache entries are scoped to the current database. See Query Result Cache for query-shape rules.

Transactions

CamusDB transactions are exposed through BeginTransactionAsync():

CamusTransaction tx = await connection.BeginTransactionAsync();

await using CamusCommand insert = connection.CreateCamusCommand("""
INSERT INTO robots (id, name, year)
VALUES (GEN_ID(), @name, @year)
""");

insert.Transaction = tx;
insert.Parameters.Add("@name", ColumnType.String, "HAL 9000");
insert.Parameters.Add("@year", ColumnType.Integer64, 1968);

await insert.ExecuteNonQueryAsync();
await tx.CommitAsync();

Use await tx.RollbackAsync() to abort the transaction.

The driver only accepts IsolationLevel.Serializable and IsolationLevel.Unspecified, which matches CamusDB's transaction model. Unspecified transactions inherit CamusDB's server default, which is Serializable.

The current ADO.NET driver does not expose a Read Committed transaction option. Use SQL, the HTTP API, or the gRPC API directly if you need to opt a transaction down to Read Committed.

Serializable Retries

Serializable is the default isolation level in CamusDB. When two serializable read-write transactions conflict, one transaction is aborted and the whole unit of work must be replayed from the beginning.

The client package includes SerializableRetryHelper for that retry contract. Only these CamusDB error codes are treated as retryable:

CodeNameMeaning
CADB0502TransactionConflictA lock conflict aborted the transaction.
CADB0504TransactionMustRetryA pre-write transient routing, leader-transition, lock-wait, or storage conflict condition exhausted internal retries.
CADB0505TransactionLifetimeExceededA serializable read-write transaction exceeded the server lifetime cap.

CADB0509 TransactionFinalizeUnresolved is intentionally not part of this replay helper. It means a commit or rollback has not reached a terminal answer; retry the same finalize on the same transaction instead of replaying the operation from the beginning.

Use SerializableRetryHelper.IsRetryable(...) when you own the retry loop:

catch (CamusException ex) when (SerializableRetryHelper.IsRetryable(ex))
{
// Replay the whole transaction from the beginning.
}

For single-statement autocommit work, use SerializableRetryHelper.ExecuteAutocommitAsync(...):

await SerializableRetryHelper.ExecuteAutocommitAsync(async ct =>
{
CamusTransaction tx = await connection.BeginTransactionAsync(ct);
try
{
await using CamusCommand update = connection.CreateCamusCommand("""
UPDATE robots SET price = @price WHERE name = @name
""");

update.Transaction = tx;
update.Parameters.Add("@price", ColumnType.Float64, 99.0);
update.Parameters.Add("@name", ColumnType.String, "T-800");

await update.ExecuteNonQueryAsync(ct);
await tx.CommitAsync(ct);
}
catch
{
await tx.RollbackAsync(ct);
throw;
}
}, maxAttempts: 5, cancellationToken);

For explicit multi-statement transactions, do not retry only the failed statement. Start a new transaction and rerun every read and write in the unit:

const int MaxAttempts = 5;

for (int attempt = 1; ; attempt++)
{
CamusTransaction tx = await connection.BeginTransactionAsync();
try
{
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;
}
}

The helper's default backoff is bounded exponential delay with jitter: min(20 ms * 2^attempt, 400 ms) plus or minus 25 percent.

ADO.NET Notes

  • The provider can use REST/JSON or gRPC with the same ADO.NET surface. Select gRPC with Protocol=grpc and point Endpoint at the gRPC listener.
  • Cancel() is cooperative through cancellation tokens.
  • Concurrent reads can share a connection session.
  • Transaction-scoped commands are pinned to the transaction endpoint, which is important when the connection string contains multiple endpoints.

When To Use It

Use the ADO.NET provider when you want:

  • full control over SQL text
  • direct use of CamusDB-specific SQL features
  • lightweight integration without EF Core
  • explicit transaction handling

For higher-level ORM usage, see EF Core Provider.