feat: initial Robotico.Nexus.Client (NexusClient, Graph/Time ports, Document/Relational helpers)
ci/woodpecker/push/woodpecker Pipeline failed Details

Unified gRPC client SDK for Mishima Nexus: nexus+graph|time|document|relational
connection URIs, TLS/mTLS/bearer auth mirroring Wire.Clients env vars,
IGraphStore/ITimeStore facades over the generated Wire.V1 stubs, thin
Document/Relational connect helpers, and NXS_* Result-based error mapping.
No Mishima.Nexus.Node.* / Mishima.Nexus.Abstractions dependency (see ADR 0001).
158 unit/integration tests (in-process fake gRPC server) at 94% line coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Sergej Kern 2026-07-25 03:44:53 +02:00
commit cbae4d1aa2
70 changed files with 5254 additions and 0 deletions

148
.editorconfig Normal file
View File

@ -0,0 +1,148 @@
# Robotico.Time — EditorConfig (Robotico library 10/10 principal bar)
# https://editorconfig.org
root = true
[*]
indent_style = space
indent_size = 4
end_of_line = crlf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.{cs,csx,vb,vbx}]
dotnet_sort_system_directives_first = true
dotnet_separate_import_directive_groups = false
csharp_style_namespace_declarations = file_scoped:error
# Naming
dotnet_naming_rule.private_fields_underscore.symbols = private_fields
dotnet_naming_rule.private_fields_underscore.style = underscore_prefix
dotnet_naming_rule.private_fields_underscore.severity = suggestion
dotnet_naming_symbols.private_fields.applicable_kinds = field
dotnet_naming_symbols.private_fields.applicable_accessibilities = private
dotnet_naming_style.underscore_prefix.required_prefix = _
dotnet_naming_style.underscore_prefix.capitalization = camel_case
dotnet_naming_rule.async_methods_end_with_async.symbols = async_methods
dotnet_naming_rule.async_methods_end_with_async.style = async_suffix
dotnet_naming_rule.async_methods_end_with_async.severity = warning
dotnet_naming_symbols.async_methods.applicable_kinds = method
dotnet_naming_symbols.async_methods.required_modifiers = async
dotnet_naming_style.async_suffix.required_suffix = Async
dotnet_naming_style.async_suffix.capitalization = pascal_case
dotnet_naming_rule.constants_pascal.symbols = const_fields
dotnet_naming_rule.constants_pascal.style = pascal_case_style
dotnet_naming_rule.constants_pascal.severity = suggestion
dotnet_naming_symbols.const_fields.applicable_kinds = field
dotnet_naming_symbols.const_fields.required_modifiers = const
dotnet_naming_style.pascal_case_style.capitalization = pascal_case
# Explicit types (no var)
dotnet_diagnostic.IDE0008.severity = error
csharp_style_var_for_built_in_types = false:error
csharp_style_var_when_type_is_apparent = false:error
csharp_style_var_elsewhere = false:error
csharp_style_expression_bodied_methods = when_on_single_line:error
csharp_style_expression_bodied_constructors = false:suggestion
csharp_style_expression_bodied_properties = when_on_single_line:suggestion
csharp_style_expression_bodied_accessors = when_on_single_line:suggestion
csharp_style_expression_bodied_lambdas = when_on_single_line:suggestion
csharp_prefer_braces = true:error
csharp_prefer_simple_using_statement = false:suggestion
dotnet_style_qualification_for_field = false:suggestion
dotnet_style_readonly_field = true:suggestion
csharp_new_line_before_open_brace = all
# CA bar (warnings)
dotnet_diagnostic.CA2007.severity = warning
dotnet_diagnostic.CA1062.severity = warning
dotnet_diagnostic.CA1812.severity = warning
dotnet_diagnostic.CA1852.severity = suggestion
dotnet_diagnostic.CA1725.severity = warning
dotnet_diagnostic.CA1816.severity = warning
dotnet_diagnostic.CA1305.severity = warning
# -----------------------------------------------------------------------------
# Principal enforcement (IDE / MA / CS8604) — errors; last wins
# -----------------------------------------------------------------------------
dotnet_diagnostic.IDE0004.severity = error
dotnet_diagnostic.IDE0005.severity = error
dotnet_diagnostic.IDE0008.severity = error
dotnet_diagnostic.IDE0011.severity = error
dotnet_diagnostic.IDE0017.severity = error
dotnet_diagnostic.IDE0018.severity = error
dotnet_diagnostic.IDE0020.severity = error
dotnet_diagnostic.IDE0022.severity = error
dotnet_diagnostic.IDE0028.severity = error
dotnet_diagnostic.IDE0030.severity = error
dotnet_diagnostic.IDE0031.severity = error
dotnet_diagnostic.IDE0034.severity = error
dotnet_diagnostic.IDE0035.severity = error
dotnet_diagnostic.IDE0036.severity = error
dotnet_diagnostic.IDE0037.severity = error
dotnet_diagnostic.IDE0038.severity = error
dotnet_diagnostic.IDE0062.severity = error
dotnet_diagnostic.IDE0063.severity = error
dotnet_diagnostic.IDE0066.severity = error
dotnet_diagnostic.IDE0072.severity = error
dotnet_diagnostic.IDE0074.severity = error
dotnet_diagnostic.IDE0090.severity = error
dotnet_diagnostic.IDE0161.severity = error
dotnet_diagnostic.IDE0290.severity = error
dotnet_diagnostic.IDE0301.severity = error
dotnet_diagnostic.IDE0303.severity = error
dotnet_diagnostic.IDE0305.severity = error
dotnet_diagnostic.IDE0330.severity = error
dotnet_diagnostic.IDE0270.severity = error
dotnet_diagnostic.IDE0061.severity = error
dotnet_diagnostic.IDE0300.severity = none
dotnet_diagnostic.CS8604.severity = error
# Meziantou — enforced set (must also be absent from MeziantouNoWarn.props)
dotnet_diagnostic.MA0004.severity = error
dotnet_diagnostic.MA0011.severity = error
dotnet_diagnostic.MA0025.severity = error
dotnet_diagnostic.MA0040.severity = error
dotnet_diagnostic.MA0042.severity = error
dotnet_diagnostic.MA0043.severity = error
dotnet_diagnostic.MA0048.severity = error
dotnet_diagnostic.MA0049.severity = error
dotnet_diagnostic.MA0051.severity = error
dotnet_diagnostic.MA0053.severity = error
dotnet_diagnostic.MA0057.severity = error
dotnet_diagnostic.MA0058.severity = error
dotnet_diagnostic.MA0059.severity = error
dotnet_diagnostic.MA0067.severity = error
dotnet_diagnostic.MA0089.severity = error
dotnet_diagnostic.MA0141.severity = error
dotnet_diagnostic.MA0142.severity = error
dotnet_diagnostic.MA0144.severity = error
dotnet_diagnostic.MA0148.severity = error
dotnet_diagnostic.MA0149.severity = error
dotnet_diagnostic.MA0155.severity = error
dotnet_diagnostic.MA0156.severity = error
dotnet_diagnostic.MA0158.severity = error
dotnet_diagnostic.MA0176.severity = error
MA0051.maximum_lines_per_method = 200
MA0051.maximum_statements_per_method = 150
csharp_style_implicit_object_creation_when_type_is_apparent = true:error
csharp_style_prefer_collection_expression = true:error
dotnet_style_prefer_collection_expression = true:error
csharp_prefer_system_threading_lock = true:error
[*.{json,yml,yaml}]
indent_size = 2
[*.md]
trim_trailing_whitespace = false
[*.adoc]
trim_trailing_whitespace = false

11
.github/dependabot.yml vendored Normal file
View File

@ -0,0 +1,11 @@
version: 2
updates:
- package-ecosystem: nuget
directory: "/"
schedule:
interval: weekly
open-pull-requests-limit: 10
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: monthly

35
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,35 @@
name: CI
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
permissions:
contents: read
packages: read
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
build-test-verify:
runs-on: ubuntu-latest
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 1
- uses: actions/setup-dotnet@v5
with:
dotnet-version: "10.0.x"
- name: Quality gate (analyzers + tests + coverlet ≥ 90%)
run: bash scripts/woodpecker-verify.sh
- name: Upload coverage
uses: codecov/codecov-action@v5
if: github.event_name == 'push' || github.event_name == 'pull_request'
with:
directory: ./coverage
fail_ci_if_error: false

68
.github/workflows/publish.yml vendored Normal file
View File

@ -0,0 +1,68 @@
name: Build and Publish
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
release:
types: [published]
workflow_dispatch:
permissions:
contents: read
packages: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
build-and-test:
runs-on: ubuntu-latest
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
steps:
- uses: actions/checkout@v6
- uses: actions/setup-dotnet@v5
with:
dotnet-version: "10.0.x"
- name: Quality gate (analyzers + tests + coverlet ≥ 90%)
run: bash scripts/woodpecker-verify.sh
- name: Upload coverage
uses: codecov/codecov-action@v5
if: github.event_name == 'push' || github.event_name == 'pull_request'
with:
directory: ./coverage
fail_ci_if_error: false
publish-nuget:
needs: [build-and-test]
runs-on: ubuntu-latest
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
if: github.event_name == 'release' && github.event.action == 'published' || github.event_name == 'workflow_dispatch'
steps:
- uses: actions/checkout@v6
- uses: actions/setup-dotnet@v5
with:
dotnet-version: "10.0.x"
- name: Derive version from tag or Directory.Build.props
id: version
shell: bash
run: |
if [ "${{ github.event_name }}" = "release" ]; then
echo "version=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_OUTPUT"
else
VER=$(grep '<Version>' Directory.Build.props | head -1 | sed 's/.*<Version>\([^<]*\)<\/Version>.*/\1/')
echo "version=$VER" >> "$GITHUB_OUTPUT"
fi
- name: Restore and pack
run: |
dotnet restore src/Robotico.Nexus.Client/Robotico.Nexus.Client.csproj
dotnet pack src/Robotico.Nexus.Client/Robotico.Nexus.Client.csproj -c Release --no-restore -p:Version=${{ steps.version.outputs.version }} -o artifacts
- name: Push to download.robotico.dev
if: env.PUBLISH_TOKEN != ''
env:
PUBLISH_TOKEN: ${{ secrets.PUBLISH_TOKEN }}
run: |
bash scripts/nuget-push-download.sh ./artifacts

62
.gitignore vendored Normal file
View File

@ -0,0 +1,62 @@
# Repo-root Robotico.Library.*.props are vendored MSBuild defaults (standalone GitHub CI).
# Build
[Dd]ebug/
[Rr]elease/
[Bb]in/
[Oo]bj/
[Oo]ut/
[Bb]uild/
[Bb]uildResult/
x64/
x86/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
msbuild.binlog
*.log
# Visual Studio
.vs/
*.user
*.userosscache
*.suo
*.userprefs
*.sln.docstates
# NuGet
*.nupkg
*.snupkg
*.nuget.props
*.nuget.targets
project.lock.json
project.fragment.lock.json
artifacts/
packages/
# Test / coverage
[Tt]est[Rr]esult*/
[Cc]overage/
*.coverage
*.coveragexml
coverage*.json
coverage*.xml
# BenchmarkDotNet
BenchmarkDotNet.Artifacts/
# IDE / Editor
.idea/
*.swp
*~
# OS
.DS_Store
Thumbs.db
# CodeRush
*.cr.userlog
# Doc build (if any)
doc/build/
nupkgs/

35
.woodpecker.yml Normal file
View File

@ -0,0 +1,35 @@
# Woodpecker — robotico-nexus-client-csharp
# Quality gate: analyzers + tests + coverlet ≥ 90% (scripts/woodpecker-verify.sh)
# Release: Version in Directory.Build.props → tag v{Version} → pack → download.robotico.dev
when:
- event: pull_request
- event: push
branch: main
steps:
restore-build-test:
image: mcr.microsoft.com/dotnet/sdk:10.0
commands:
- bash scripts/woodpecker-verify.sh
when:
- event: [pull_request, push]
release:
image: mcr.microsoft.com/dotnet/sdk:10.0
environment:
PUBLISH_TOKEN:
from_secret: publish_token
GITEA_TOKEN:
from_secret: gitea_token
PACK_VERSION_PROJECT: src/Robotico.Nexus.Client/Robotico.Nexus.Client.csproj
commands:
- apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends git ca-certificates
- bash scripts/woodpecker-verify.sh
- bash scripts/woodpecker-release.sh
when:
- event: push
branch: main
path:
exclude:
- README.md

24
CHANGELOG.md Normal file
View File

@ -0,0 +1,24 @@
# Changelog
## 1.0.0
- Initial `NexusClient`: connects via `nexus+graph|time|document|relational://` URIs (explicit port
required, `nexus+sql://` rejected with a dedicated message), TLS/mTLS/plaintext h2c channel setup
mirroring `Mishima.Nexus.Wire.Clients` environment variables, `x-nexus-database` metadata bound
from the URI path (or scheme's canonical default), and `CheckHealth()` via `NexusHealthService`.
- `Robotico.Nexus.Client.Graph`: `NexusGraphStore` / `NexusGraphSession` implement `IGraphStore` /
`IGraphSession` over `NexusGraphEngineService` — no Cypher/Gremlin/SPARQL/Vacuum surface.
- `Robotico.Nexus.Client.Time`: `NexusTimeStore` / `NexusTimeSession` implement `ITimeStore` /
`ITimeSession` over `NexusTimeEngineService`, including required `Doctor()`.
- `Robotico.Nexus.Client.Document` / `.Relational`: thin connect helpers (`NexusDocumentClient`,
`NexusRelationalClient`) plus generated Wire.V1 client factories
(`CreateDocumentWireClient()` / `CreateRelationalWireClient()`) for the full RPC surface.
- `Create`/`Open`/`OpenOrCreate` POLA semantics documented and implemented: `OpenOrCreate` ==
`Open`; `Create` fails with `NXS_ALREADY_EXISTS` once `Ping` succeeds.
- `NexusErrors` (`NXS_URI_INVALID`, `NXS_URI_UNSUPPORTED_SCHEME`, `NXS_CONNECT_FAILED`,
`NXS_TLS_REJECTED`, `NXS_ALREADY_EXISTS`, `NXS_NOT_FOUND`, `NXS_INVALID_ARGUMENT`, `NXS_DISPOSED`,
`NXS_UNAVAILABLE`, `NXS_WIRE_FAILED`) map both `NexusStatus` wire envelopes and `RpcException`
failures to `Robotico.Result.Result`.
- No dependency on `Mishima.Nexus.Abstractions` or any `Mishima.Nexus.Node.*` assembly — URI parsing
and TLS material handling are duplicated minimally to avoid pulling F# config/domain assemblies
into client apps (see `docs/adr/0001-nexus-client-monolith.adoc`).

44
Directory.Build.props Normal file
View File

@ -0,0 +1,44 @@
<Project>
<PropertyGroup>
<_RoboticoSharedMsbuildImport Condition="Exists('$(MSBuildThisFileDirectory)Robotico.Library.Shared.props')">$(MSBuildThisFileDirectory)Robotico.Library.Shared.props</_RoboticoSharedMsbuildImport>
<_RoboticoSharedMsbuildImport Condition="'$(_RoboticoSharedMsbuildImport)' == ''">$([MSBuild]::GetPathOfFileAbove('Robotico.Library.Shared.props', '$(MSBuildThisFileDirectory)'))</_RoboticoSharedMsbuildImport>
</PropertyGroup>
<Import Project="$(_RoboticoSharedMsbuildImport)" Condition="'$(_RoboticoSharedMsbuildImport)' != ''" />
<!-- Mishima.Nexus.Wire.V1 targets net10.0 only. -->
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
</PropertyGroup>
<PropertyGroup>
<Version>1.0.0</Version>
<Product>Robotico.Nexus.Client</Product>
<PackageId>Robotico.Nexus.Client</PackageId>
<Title>Robotico.Nexus.Client</Title>
<Description>Unified Nexus client SDK: connect via nexus+graph|time|document|relational URIs, get Robotico.Graph / Robotico.Time ports bound to a remote Nexus database over gRPC, plus thin Document/Relational client factories. Does not reference Mishima.Nexus.Node.* host assemblies.</Description>
<PackageTags>nexus;mishima;grpc;robotico;client;graph;time</PackageTags>
<Authors>robotico.dev Kft.</Authors>
<Company>robotico.dev Kft.</Company>
<Copyright>Copyright © 2026 robotico.dev Kft.</Copyright>
<PackageProjectUrl>https://download.robotico.dev</PackageProjectUrl>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
</PropertyGroup>
<PropertyGroup>
<_RoboticoCsharpNuGetsDir>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\nugets'))</_RoboticoCsharpNuGetsDir>
</PropertyGroup>
<PropertyGroup Condition="Exists('$(_RoboticoCsharpNuGetsDir)')">
<PackageOutputPath>$([MSBuild]::EnsureTrailingSlash('$(_RoboticoCsharpNuGetsDir)'))</PackageOutputPath>
</PropertyGroup>
<!-- Dual-mode: ProjectReference in the monorepo, PackageReference (feed) for standalone/CI clones. -->
<PropertyGroup>
<RoboticoGraphHubProjectPath>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\robotico-graph-csharp\src\Robotico.Graph\Robotico.Graph.csproj'))</RoboticoGraphHubProjectPath>
<RoboticoTimeHubProjectPath>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\robotico-time-csharp\src\Robotico.Time\Robotico.Time.csproj'))</RoboticoTimeHubProjectPath>
<MishimaNexusWireV1ProjectPath>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\..\mishima-suite\mishima-nexus\src\Mishima.Nexus.Wire.V1\Mishima.Nexus.Wire.V1.csproj'))</MishimaNexusWireV1ProjectPath>
</PropertyGroup>
</Project>

19
Directory.Packages.props Normal file
View File

@ -0,0 +1,19 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Meziantou.Analyzer" Version="3.0.125" />
<PackageVersion Include="coverlet.collector" Version="10.0.1" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="10.0.301" />
<PackageVersion Include="Robotico.Result" Version="1.0.3" />
<PackageVersion Include="Robotico.Graph" Version="1.0.0" />
<PackageVersion Include="Robotico.Time" Version="1.0.0" />
<PackageVersion Include="Mishima.Nexus.Wire.V1" Version="0.4.9" />
<PackageVersion Include="Grpc.Net.Client" Version="2.67.0" />
<PackageVersion Include="Grpc.AspNetCore" Version="2.67.0" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
</ItemGroup>
</Project>

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 robotico.dev
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

114
README.md Normal file
View File

@ -0,0 +1,114 @@
# Robotico.Nexus.Client
Unified client SDK for a remote [Mishima Nexus](https://brokkr.robotico.dev/robotico/mishima-suite)
server: connect via a `nexus+*://` URI and get `Robotico.Graph` / `Robotico.Time` ports bound to a
logical database over gRPC, plus thin Document/Relational connect helpers.
- NuGet: `Robotico.Nexus.Client`
- Feed: `https://download.robotico.dev/nuget/v3/index.json`
- Brokkr: `https://brokkr.robotico.dev/robotico/robotico-nexus-client-csharp`
This package does **not** reference `Mishima.Nexus.Node.*` (the server host assemblies) — it only
depends on the generated `Mishima.Nexus.Wire.V1` gRPC stubs, `Robotico.Graph`, and `Robotico.Time`.
## Quick start
```csharp
using Robotico.Nexus.Client;
Result<NexusClient> connected = NexusClient.Connect("nexus+graph://localhost:50051/pathways");
using NexusClient client = connected.ExpectSuccess();
client.CheckHealth().ExpectSuccess();
Result<IGraphSession> opened = client.Graph.Open("pathways");
using IGraphSession session = opened.ExpectSuccess();
session.RegisterLabel("Person").ExpectSuccess();
session.UpsertVertex(1, ["Person"]).ExpectSuccess();
```
## Connection URIs
| Scheme | Plane | Default database (empty path) |
|-----------------------|------------|--------------------------------|
| `nexus+graph://` | Graph | `pathways` |
| `nexus+time://` | Time | `telemetry` |
| `nexus+document://` | Document | `catalog` |
| `nexus+relational://` | Relational | `ledger` |
| `nexus+sql://` | — | rejected — use `Mishima.Nexus.Sql` directly |
- The URI **host:port** is the gRPC endpoint; the **port is always explicit** (no default port is
ever inferred, so a typo can't silently connect to the wrong service).
- The URI **path** (e.g. `/pathways`) is the **logical database name**, sent as the
`x-nexus-database` gRPC metadata header on every call — it is not a filesystem path, even though
the underlying `IGraphStore`/`ITimeStore` port parameter is historically named
`databaseFilePath`.
- One `NexusClient` is bound to a single scheme + logical database from the connect URI. Open a
second client to talk to another plane or database.
## TLS / mTLS / bearer auth
`NexusClientOptions` mirrors `Mishima.Nexus.Wire.Clients.WireTlsMaterial`'s environment variables
(same names, no shared assembly dependency):
| Variable | Purpose |
|---------------------------------|-------------------------------------------|
| `NEXUS_TLS_CA_FILE` | CA certificate PEM (trust root) |
| `NEXUS_TLS_CLIENT_CERT_FILE` | Client (machine credential) certificate |
| `NEXUS_TLS_CLIENT_KEY_FILE` | Client private key PEM |
| `NEXUS_TLS_MODE` | `https` (default) or `insecure` |
| `NEXUS_WIRE_BEARER` | Bearer token attached as `Authorization` |
| `NEXUS_WIRE_DEFAULT_BEARER` | Fallback bearer token |
Pass an explicit `NexusTlsMaterial` / `BearerToken` via `NexusClientOptions` to override the
environment. When no TLS material is present and `UseHttps` is unset, the client dials plaintext
h2c (loopback/dev only).
## Create / Open / OpenOrCreate semantics (POLA)
Nexus provisions logical databases lazily — the host creates a database's on-disk state the first
time an RPC actually touches it, not via a separate "create database" call on the Graph/Time wire
services. This client documents that behavior explicitly rather than pretending otherwise:
- **`Open(name)`** — pings the engine with `x-nexus-database: name` and returns a session bound to
that logical name. Succeeds even for a database the host hasn't materialized yet (the host
creates it lazily on first write).
- **`OpenOrCreate(name)`** — identical to `Open(name)`. There is no separate provisioning step to
race against, so "open if exists, else create" collapses to "open" once the engine is reachable.
- **`Create(name)`** — fails with `NXS_ALREADY_EXISTS` when `Ping` against `name` succeeds, because
Nexus's `Ping` is an **engine-liveness** probe, not a **per-database existence** check — once the
Graph/Time engine is reachable at all, `Create` cannot distinguish "database already has data"
from "database is merely reachable". Use `OpenOrCreate` for greenfield databases instead.
## Document / Relational
`NexusClient.CreateDocumentClient()` / `CreateRelationalClient()` return small façades (`Ping` plus,
for Document, single-document upsert/get/delete) — enough for MVP smoke usage. They are **not** a
full `IMishimaDatabase` façade. For the complete generated RPC surface, use
`CreateDocumentWireClient()` / `CreateRelationalWireClient()`, which return the raw
`Mishima.Nexus.Wire.V1` gRPC clients bound to the same channel and `x-nexus-database` metadata.
## Error codes
All failures map to `Robotico.Result.Result` with `NXS_*` codes (`NexusErrors`): `NXS_URI_INVALID`,
`NXS_URI_UNSUPPORTED_SCHEME`, `NXS_CONNECT_FAILED`, `NXS_TLS_REJECTED`, `NXS_ALREADY_EXISTS`,
`NXS_NOT_FOUND`, `NXS_INVALID_ARGUMENT`, `NXS_DISPOSED`, `NXS_UNAVAILABLE`, `NXS_WIRE_FAILED`.
`Robotico.Graph` (`GR_*`) and `Robotico.Time` (`TM_*`) codes surface unchanged from the ports.
## Quality gate
Same Library 10 bar as other Robotico C# packages:
- Analyzers on (`TreatWarningsAsErrors`, Meziantou MA0004/MA0042/MA0155, IDE0008/IDE0090/IDE0301/IDE0330)
- `dotnet test` with coverlet **≥ 90%** line coverage on `Robotico.Nexus.Client`, exercised against
an in-process fake gRPC server (Kestrel + in-memory fake `NexusGraphEngineService` /
`NexusTimeEngineService` / `NexusDocumentEngineService` / `NexusRelationalEngineService` /
`NexusHealthService`) — no live Nexus process required.
- Woodpecker / GitHub CI run `bash scripts/woodpecker-verify.sh`
## Build
```bash
bash scripts/woodpecker-verify.sh
bash scripts/pack-release.sh
```

View File

@ -0,0 +1,43 @@
<Project>
<!--
Language-version-agnostic analyzer and packaging defaults shared by all Robotico C# projects.
Import from Robotico.Library.Shared.props (multi-TFM libraries) or standalone repos (e.g. single-TFM CLI).
A copy lives in each package repo; run csharp/tools/sync-robotico-library-props.ps1 after editing this file.
-->
<PropertyGroup>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<WarningLevel>8</WarningLevel>
<AnalysisLevel>latest-all</AnalysisLevel>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<Authors>Robotico</Authors>
<Company>Robotico</Company>
<Copyright>Copyright © Robotico</Copyright>
<RepositoryType>git</RepositoryType>
<EnableNETAnalyzers>true</EnableNETAnalyzers>
<AnalysisMode>All</AnalysisMode>
<PackageReadmeFile>README.md</PackageReadmeFile>
</PropertyGroup>
<!--
Meziantou.Analyzer — MA0004 (ConfigureAwait), MA0042 (blocking in async), MA0155 (async void).
Severity set to error in each repo’s .editorconfig. When central package management is enabled,
add <PackageVersion Include="Meziantou.Analyzer" Version="…" /> to Directory.Packages.props.
-->
<ItemGroup Condition="'$(ManagePackageVersionsCentrally)' == 'true'">
<PackageReference Include="Meziantou.Analyzer">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup Condition="'$(ManagePackageVersionsCentrally)' != 'true'">
<PackageReference Include="Meziantou.Analyzer" Version="3.0.49">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<Import Project="$(MSBuildThisFileDirectory)Robotico.Library.MeziantouNoWarn.props" Condition="Exists('$(MSBuildThisFileDirectory)Robotico.Library.MeziantouNoWarn.props')" />
</Project>

View File

@ -0,0 +1,6 @@
<Project>
<!-- Suppress Meziantou MA* except enforced set in .editorconfig. TreatWarningsAsErrors otherwise upgrades all MA warnings. -->
<PropertyGroup>
<NoWarn>$(NoWarn);MA0001;MA0002;MA0003;MA0005;MA0006;MA0007;MA0008;MA0009;MA0010;MA0012;MA0013;MA0014;MA0015;MA0016;MA0017;MA0018;MA0019;MA0020;MA0021;MA0022;MA0023;MA0024;MA0026;MA0027;MA0028;MA0029;MA0030;MA0031;MA0032;MA0033;MA0034;MA0035;MA0036;MA0037;MA0038;MA0039;MA0041;MA0044;MA0045;MA0046;MA0047;MA0050;MA0052;MA0054;MA0055;MA0056;MA0060;MA0061;MA0062;MA0063;MA0064;MA0065;MA0066;MA0068;MA0069;MA0070;MA0071;MA0072;MA0073;MA0074;MA0075;MA0076;MA0077;MA0078;MA0079;MA0080;MA0081;MA0082;MA0083;MA0084;MA0085;MA0086;MA0087;MA0088;MA0090;MA0091;MA0092;MA0093;MA0094;MA0095;MA0096;MA0097;MA0098;MA0099;MA0100;MA0101;MA0102;MA0103;MA0104;MA0105;MA0106;MA0107;MA0108;MA0109;MA0110;MA0111;MA0112;MA0113;MA0114;MA0115;MA0116;MA0117;MA0118;MA0119;MA0120;MA0121;MA0122;MA0123;MA0124;MA0125;MA0126;MA0127;MA0128;MA0129;MA0130;MA0131;MA0132;MA0133;MA0134;MA0135;MA0136;MA0137;MA0138;MA0139;MA0140;MA0143;MA0145;MA0146;MA0147;MA0150;MA0151;MA0152;MA0153;MA0154;MA0157;MA0159;MA0160;MA0161;MA0162;MA0163;MA0164;MA0165;MA0166;MA0167;MA0168;MA0169;MA0170;MA0171;MA0172;MA0173;MA0174;MA0175;MA0177;MA0178;MA0179;MA0180;MA0181;MA0182;MA0183;MA0184;MA0185;MA0186;MA0187;MA0188;MA0189;MA0190;MA0191;MA0192;MA0193;MA0194;MA0195;MA0196;MA0197;MA0198;MA0199;MA0200;MA0201;MA0202;MA0203;MA0204;MA0205;MA0206;MA0207;MA0208;MA0209;MA0210;MA0211;MA0212;MA0213;MA0214;MA0215;MA0216;MA0217;MA0218;MA0219;MA0220;MA0221;MA0222;MA0223;MA0224;MA0225;MA0226;MA0227;MA0228;MA0229;MA0230;MA0231;MA0232;MA0233;MA0234;MA0235;MA0236;MA0237;MA0238;MA0239;MA0240;MA0241;MA0242;MA0243;MA0244;MA0245;MA0246;MA0247;MA0248;MA0249;MA0250</NoWarn>
</PropertyGroup>
</Project>

View File

@ -0,0 +1,39 @@
<Project>
<!--
Shared library defaults for Robotico C# packages under this workspace.
Each package repo also contains a copy (GitHub/standalone); Directory.Build.props
prefers that path, then GetPathOfFileAbove for monorepo csharp/ layout. After edits
here, run csharp/tools/sync-robotico-library-props.ps1.
-->
<Import Project="$(MSBuildThisFileDirectory)Robotico.Library.AnalyzerDefaults.props" />
<PropertyGroup>
<TargetFrameworks>net8.0;net10.0</TargetFrameworks>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<!--
Monorepo local NuGet feed (csharp/nugets): set environment variable ROBOTICO_USE_LOCAL_NUGETS=true
and run csharp/tools/pack-robotico-tier0-local-nugets.ps1 (and later tier scripts). Standalone
clones on GitHub omit nuget.Local.config above the repo; GetPathOfFileAbove returns empty and
this block does nothing. See csharp/build/ROBOTICO_NUGET_DUAL_MODE_PLAN.adoc.
-->
<PropertyGroup>
<_RoboticoLocalNugetConfig>$([MSBuild]::GetPathOfFileAbove('nuget.Local.config', '$(MSBuildThisFileDirectory)'))</_RoboticoLocalNugetConfig>
</PropertyGroup>
<PropertyGroup Condition="'$(ROBOTICO_USE_LOCAL_NUGETS)' == 'true' And '$(_RoboticoLocalNugetConfig)' != '' And Exists('$(_RoboticoLocalNugetConfig)')">
<RestoreConfigFile>$(_RoboticoLocalNugetConfig)</RestoreConfigFile>
</PropertyGroup>
</Project>

View File

@ -0,0 +1,68 @@
= ADR 0001 — Nexus client monolith, no Node.* references, ports-only for Graph/Time
:revdate: 2026-07-25
== Status
Accepted.
== Context
Robotico apps that talk to a remote Mishima Nexus server need a single, small SDK: parse a
`nexus+*://` connection URI, dial the right gRPC plane, and expose the same `Robotico.Graph` /
`Robotico.Time` ports application code already codes against for local/embedded engines. Nexus
exposes four wire planes (Graph, Time, Document, Relational) plus a SQL-compatibility plane that is
explicitly out of scope for this client. `Mishima.Nexus.Abstractions` (the server-side config/URI
library) pulls in `Mishima.Nexus.Config.FSharp` and `Mishima.Nexus.Domain`, which are not published
to the NuGet feed and are not appropriate dependencies for a thin remote client.
== Decision
* Publish a single package, `Robotico.Nexus.Client` (YAGNI — no `Robotico.Nexus.Client.Graph.dll`
split etc.), with internal namespaces `Robotico.Nexus.Client`, `.Graph`, `.Time`, `.Document`,
`.Relational`.
* `NexusClient.Connect(connectionUri, options)` is the single entry point: parses the URI, dials a
gRPC channel (TLS/mTLS or plaintext h2c), and exposes `Graph` (`IGraphStore`), `Time`
(`ITimeStore`), `CheckHealth()`, and `CreateDocumentClient()` / `CreateRelationalClient()`.
* **Do not reference `Mishima.Nexus.Abstractions` or any `Mishima.Nexus.Node.*` assembly.** The URI
parsing logic (`NexusConnectionUri`) and TLS material handling (`NexusTlsMaterial`) are
intentionally duplicated (not shared) from the server-side equivalents, using the same URI shape
and the same environment variable names (POLA) so operators don't need to learn two conventions.
This keeps the client's dependency graph to `Mishima.Nexus.Wire.V1` (generated gRPC stubs only),
`Robotico.Graph`, `Robotico.Time`, and `Robotico.Result` — no F# assemblies, no server config, no
host process wiring.
* `nexus+sql://` is recognized and rejected with a dedicated error message pointing at
`Mishima.Nexus.Sql` — this client never speaks the SQL-compatibility wire.
* Graph and Time are **ports-only**: `NexusGraphStore`/`NexusGraphSession` implement `IGraphStore`/
`IGraphSession` and `NexusTimeStore`/`NexusTimeSession` implement `ITimeStore`/`ITimeSession`
exactly — no Cypher/Gremlin/SPARQL/Vacuum surface on `IGraphSession`, and `Doctor()` is always
available on `ITimeSession` (synthesized from `NexusTimeEngineService.Doctor`, since Nexus does
not expose server-side filesystem paths to remote clients — `PrimaryPath`/`WalPath` report the
logical `nexus://` identity instead, and `MaxActiveSeries` is always `0`).
* Document and Relational get **thin connect helpers**, not a full `IMishimaDatabase` façade:
`NexusDocumentClient` (Ping + single-document upsert/get/delete) and `NexusRelationalClient`
(Ping only). `CreateDocumentWireClient()` / `CreateRelationalWireClient()` return the generated
Wire.V1 clients directly for callers who need the full RPC surface.
* `Create`/`Open`/`OpenOrCreate` on `IGraphStore`/`ITimeStore` are logical-database-name operations
(the `databaseFilePath` parameter name is the port's, not this client's — see README). Nexus
provisions logical databases lazily on first use, and `Ping` is engine-level (not per-database),
so:
- `Open` == ping + bind session (works even before the host has materialized any state for that
name).
- `OpenOrCreate` == `Open` (no separate provisioning RPC to race).
- `Create` fails with `NXS_ALREADY_EXISTS` once `Ping` succeeds, since a successful `Ping` cannot
distinguish "engine reachable, database still empty" from "database already has data".
* All failures — wire status envelopes (`NexusStatus`) and gRPC (`RpcException`) — map to
`Robotico.Result.Result` with stable `NXS_*` codes (`NexusErrors`), so callers see one consistent
error surface regardless of failure layer.
== Consequences
* Apps depend on `Robotico.Nexus.Client` alongside `Robotico.Graph`/`Robotico.Time` and code against
the same ports whether the backing store is embedded (`MishimaGraph`/`MishimaTime`) or remote
(Nexus over gRPC) — swapping is a `NexusClient.Connect(...)` call, not a port change.
* If `Mishima.Nexus.Abstractions` is later published cleanly (without Node.* transitive
dependencies), this client can migrate to referencing it instead of duplicating URI/TLS parsing —
tracked as a follow-up, not required for the 1.0.0 MVP.
* Document/Relational callers who need more than Ping/basic CRUD must drop to the generated
Wire.V1 client via `CreateDocumentWireClient()`/`CreateRelationalWireClient()` until (if ever) a
richer façade ships.

6
global.json Normal file
View File

@ -0,0 +1,6 @@
{
"sdk": {
"version": "10.0.5",
"rollForward": "latestFeature"
}
}

17
nuget.config Normal file
View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
<add key="robotico-downloads" value="https://download.robotico.dev/nuget/v3/index.json" protocolVersion="3" />
</packageSources>
<packageSourceMapping>
<packageSource key="robotico-downloads">
<package pattern="Robotico.*" />
<package pattern="Mishima.*" />
</packageSource>
<packageSource key="nuget.org">
<package pattern="*" />
</packageSource>
</packageSourceMapping>
</configuration>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<Solution>
<Folder Name="/src/">
<Project Name="Robotico.Nexus.Client" Path="src/Robotico.Nexus.Client/Robotico.Nexus.Client.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Name="Robotico.Nexus.Client.Tests" Path="tests/Robotico.Nexus.Client.Tests/Robotico.Nexus.Client.Tests.csproj" />
</Folder>
</Solution>

View File

@ -0,0 +1,18 @@
#!/usr/bin/env bash
# Restore/build projects listed in scripts/pack-projects.list (gitnautica libs; no WPF).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
LIST="${ROOT}/scripts/pack-projects.list"
test -f "${ROOT}/Directory.Build.props" || { echo "MUST - Directory.Build.props required"; exit 1; }
test -f "${LIST}" || { echo "MUST - scripts/pack-projects.list required"; exit 1; }
DOTNET_FLAGS=(-p:NuGetAudit=false -p:RunAnalyzersDuringBuild=false)
while IFS= read -r proj || [[ -n "${proj}" ]]; do
[[ -z "${proj}" || "${proj}" =~ ^# ]] && continue
echo "CI pack project: ${proj}"
dotnet restore "${ROOT}/${proj}" "${DOTNET_FLAGS[@]}"
dotnet build "${ROOT}/${proj}" -c Release --no-restore "${DOTNET_FLAGS[@]}"
done < "${LIST}"

74
scripts/ensure-git-tag.sh Executable file
View File

@ -0,0 +1,74 @@
#!/usr/bin/env bash
# Create and push v{Version} if missing. Version comes from Directory.Build.props (no v prefix).
set -euo pipefail
VERSION="${1:?Usage: ensure-git-tag.sh <semver from Directory.Build.props>}"
TAG="v${VERSION}"
if [[ -z "${CI_COMMIT_SHA:-}" ]]; then
echo "error: CI_COMMIT_SHA not set — run inside Woodpecker" >&2
exit 1
fi
# Annotated tags: rev-parse must peel to the commit (^{commit}), not the tag object.
tag_commit_sha() {
local tag="$1"
git rev-parse "${tag}^{commit}" 2>/dev/null || git rev-parse "refs/tags/${tag}^{commit}"
}
remote_tag_commit_sha() {
local tag="$1"
local peeled
peeled="$(git ls-remote origin "refs/tags/${tag}^{}" 2>/dev/null | awk '{print $1}' | head -1)"
if [[ -n "${peeled}" ]]; then
printf '%s' "${peeled}"
return 0
fi
git ls-remote origin "refs/tags/${tag}" 2>/dev/null | awk '{print $1}' | head -1
}
git config user.email "${WOODPECKER_GIT_EMAIL:-woodpecker@dvalin.robotico.dev}"
git config user.name "${WOODPECKER_GIT_NAME:-Woodpecker CI}"
if [[ -n "${GITEA_TOKEN:-}" ]]; then
if [[ -n "${CI_REPO_CLONE_URL:-}" ]]; then
clone_url="${CI_REPO_CLONE_URL}"
if [[ "${clone_url}" == https://* ]]; then
clone_url="https://oauth2:${GITEA_TOKEN}@${clone_url#https://}"
fi
git remote set-url origin "${clone_url}"
elif [[ -n "${CI_REPO_OWNER:-}" && -n "${CI_REPO_NAME:-}" ]]; then
git remote set-url origin "https://oauth2:${GITEA_TOKEN}@brokkr.robotico.dev/${CI_REPO_OWNER}/${CI_REPO_NAME}.git"
elif [[ -n "${CI_REPO:-}" ]]; then
git remote set-url origin "https://oauth2:${GITEA_TOKEN}@brokkr.robotico.dev/${CI_REPO}.git"
fi
fi
git fetch origin --tags --force
if git rev-parse "refs/tags/${TAG}" >/dev/null 2>&1; then
existing="$(tag_commit_sha "${TAG}")"
if [[ "${existing}" == "${CI_COMMIT_SHA}" ]]; then
echo "[INFO] tag ${TAG} already points at ${CI_COMMIT_SHA:0:7}"
exit 0
fi
echo "error: tag ${TAG} exists at ${existing:0:7} but current commit is ${CI_COMMIT_SHA:0:7}" >&2
echo "error: bump <Version> in Directory.Build.props — tags are immutable" >&2
exit 1
fi
remote_sha="$(remote_tag_commit_sha "${TAG}" || true)"
if [[ -n "${remote_sha}" ]]; then
if [[ "${remote_sha}" == "${CI_COMMIT_SHA}" ]]; then
echo "[INFO] remote tag ${TAG} already at ${CI_COMMIT_SHA:0:7}"
exit 0
fi
echo "error: remote tag ${TAG} exists at ${remote_sha:0:7}, not ${CI_COMMIT_SHA:0:7}" >&2
echo "error: bump <Version> in Directory.Build.props" >&2
exit 1
fi
git tag -a "${TAG}" -m "Release ${VERSION} (from Directory.Build.props via Woodpecker)"
git push origin "${TAG}"
echo "[OK] created and pushed tag ${TAG}"

69
scripts/nuget-push-download.sh Executable file
View File

@ -0,0 +1,69 @@
#!/usr/bin/env bash
# Push *.nupkg to download.robotico.dev (BaGetter). Never overwrites: --skip-duplicate.
# Existing version → warning only (exit 0).
set -euo pipefail
NUGET_SOURCE="${NUGET_PUSH_SOURCE:-https://download.robotico.dev/nuget/v3/index.json}"
API_KEY="${PUBLISH_TOKEN:?Set PUBLISH_TOKEN (Woodpecker secret publish_token)}"
DIR="${1:-./nupkgs}"
if ! command -v dotnet >/dev/null 2>&1; then
echo "error: dotnet required on PATH" >&2
exit 1
fi
if [[ ! -d "$DIR" ]]; then
echo "error: nupkg directory not found: $DIR" >&2
exit 1
fi
shopt -s nullglob
files=("$DIR"/*.nupkg)
shopt -u nullglob
if [[ "${#files[@]}" -eq 0 ]]; then
echo "error: no .nupkg files in $DIR" >&2
exit 1
fi
published=0
warnings=0
skipped_symbols=0
for f in "${files[@]}"; do
base="$(basename "$f")"
if [[ "$base" == *.symbols.nupkg ]]; then
skipped_symbols=$((skipped_symbols + 1))
continue
fi
set +e
out="$(dotnet nuget push "$f" --source "$NUGET_SOURCE" --api-key "$API_KEY" --skip-duplicate 2>&1)"
code=$?
set -e
printf '%s\n' "$out"
if [[ "$code" -eq 0 ]]; then
if echo "$out" | grep -qiE 'already exists|skipped|duplicate|409|conflict'; then
echo "[WARN] $base — version already on registry (not overwritten)"
warnings=$((warnings + 1))
else
echo "[OK] $base — published"
published=$((published + 1))
fi
else
if echo "$out" | grep -qiE 'already exists|skipped|duplicate|409|conflict|already been pushed'; then
echo "[WARN] $base — version already on registry (not overwritten)"
warnings=$((warnings + 1))
else
echo "[ERROR] $base — push failed:" >&2
echo "$out" >&2
exit 1
fi
fi
done
echo "nuget-push-download: published=$published warnings=$warnings symbols_skipped=$skipped_symbols"
if [[ "$published" -eq 0 && "$warnings" -gt 0 ]]; then
echo "nuget-push-download: all packages already present — pipeline OK (no overwrite)"
fi

View File

@ -0,0 +1 @@
src/Robotico.Nexus.Client/Robotico.Nexus.Client.csproj

39
scripts/pack-release.sh Executable file
View File

@ -0,0 +1,39 @@
#!/usr/bin/env bash
# Pack projects listed in scripts/pack-projects.list; version from Directory.Build.props only.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
OUT="${ROOT}/nupkgs"
SCRIPT_DIR="$(dirname "$0")"
LIST="${SCRIPT_DIR}/pack-projects.list"
if [[ ! -f "${LIST}" ]]; then
echo "error: missing ${LIST}" >&2
exit 1
fi
VERSION="$(bash "${SCRIPT_DIR}/read-package-version.sh")"
rm -rf "${OUT}"
mkdir -p "${OUT}"
echo "pack-release: version=${VERSION} (Directory.Build.props) output=${OUT}"
while IFS= read -r proj || [[ -n "${proj}" ]]; do
[[ -z "${proj}" ]] && continue
[[ "${proj}" =~ ^# ]] && continue
proj_path="${ROOT}/${proj}"
if [[ ! -f "${proj_path}" ]]; then
echo "error: project not found: ${proj_path}" >&2
exit 1
fi
echo " pack ${proj}"
dotnet pack "${proj_path}" -c Release --nologo -o "${OUT}" -p:NuGetAudit=false
done < "${LIST}"
shopt -s nullglob
files=("${OUT}"/*.nupkg)
shopt -u nullglob
if [[ "${#files[@]}" -eq 0 ]]; then
echo "error: no packages produced" >&2
exit 1
fi
ls -la "${OUT}"/*.nupkg

42
scripts/read-package-version.sh Executable file
View File

@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Emit Version from Directory.Build.props (via MSBuild evaluation on a library project).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
PROJ="${PACK_VERSION_PROJECT:-src/Robotico.Nexus.Client/Robotico.Nexus.Client.csproj}"
PROJ_PATH="${ROOT}/${PROJ}"
if [[ ! -f "${PROJ_PATH}" ]]; then
echo "error: PACK_VERSION_PROJECT not found: ${PROJ_PATH}" >&2
exit 1
fi
if [[ ! -f "${ROOT}/Directory.Build.props" ]]; then
echo "error: missing ${ROOT}/Directory.Build.props (MUST for release)" >&2
exit 1
fi
VERSION="$(
dotnet msbuild "${PROJ_PATH}" \
-getProperty:Version \
-p:NuGetAudit=false \
-nologo \
| tr -d '\r'
)"
if [[ -z "${VERSION}" ]]; then
echo "error: Version is empty — set <Version> in Directory.Build.props" >&2
exit 1
fi
if [[ "${VERSION}" =~ ^v ]]; then
echo "error: Version must not include a 'v' prefix (use 1.0.0 not v1.0.0)" >&2
exit 1
fi
if ! [[ "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then
echo "error: Version '${VERSION}' is not a valid semver-like release id" >&2
exit 1
fi
printf '%s' "${VERSION}"

62
scripts/should-skip-release.sh Executable file
View File

@ -0,0 +1,62 @@
#!/usr/bin/env bash
# Exit 0 when this commit should not run tag/pack/push (docs/CI only since last v{Version} tag).
set -euo pipefail
if [[ -z "${CI_COMMIT_SHA:-}" ]]; then
exit 1
fi
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "${ROOT}"
if ! grep -q '<Version>' Directory.Build.props 2>/dev/null; then
exit 1
fi
VERSION="$(bash scripts/read-package-version.sh)"
TAG="v${VERSION}"
if [[ -n "${GITEA_TOKEN:-}" ]]; then
if [[ -n "${CI_REPO_CLONE_URL:-}" ]]; then
clone_url="${CI_REPO_CLONE_URL}"
if [[ "${clone_url}" == https://* ]]; then
clone_url="https://oauth2:${GITEA_TOKEN}@${clone_url#https://}"
fi
git remote set-url origin "${clone_url}"
elif [[ -n "${CI_REPO_OWNER:-}" && -n "${CI_REPO_NAME:-}" ]]; then
git remote set-url origin "https://oauth2:${GITEA_TOKEN}@brokkr.robotico.dev/${CI_REPO_OWNER}/${CI_REPO_NAME}.git"
elif [[ -n "${CI_REPO:-}" ]]; then
git remote set-url origin "https://oauth2:${GITEA_TOKEN}@brokkr.robotico.dev/${CI_REPO}.git"
fi
fi
git fetch origin --tags --force
if ! git rev-parse "refs/tags/${TAG}^{commit}" >/dev/null 2>&1; then
exit 1
fi
tag_sha="$(git rev-parse "refs/tags/${TAG}^{commit}")"
if [[ "${tag_sha}" == "${CI_COMMIT_SHA}" ]]; then
exit 1
fi
git fetch origin "${tag_sha}" --depth=1 2>/dev/null || git fetch origin "${tag_sha}" 2>/dev/null || true
while IFS= read -r path; do
[[ -z "${path}" ]] && continue
case "${path}" in
README.md|.woodpecker.yml|scripts/*|assets/*|docs/*|benchmarks/*|.github/*|CHANGELOG.md|IMPLEMENTATION*.md|global.json|coverlet.runsettings|*.slnx|Robotico.Library.*.props|LICENSE|nuget.config) ;;
*)
exit 1
;;
esac
done < <(git diff --name-only "${tag_sha}" "${CI_COMMIT_SHA}" 2>/dev/null || true)
if ! git diff --name-only "${tag_sha}" "${CI_COMMIT_SHA}" | grep -q .; then
exit 1
fi
echo "[INFO] release skipped: no packable changes since ${TAG} (${tag_sha:0:7})"
exit 0

21
scripts/woodpecker-release.sh Executable file
View File

@ -0,0 +1,21 @@
#!/usr/bin/env bash
# Woodpecker release step: read Version → tag → pack → push (single shell; avoids YAML line-join bugs).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "${ROOT}"
test -f Directory.Build.props || {
echo "MUST - Directory.Build.props with Version element" >&2
exit 1
}
if bash scripts/should-skip-release.sh; then
exit 0
fi
VERSION="$(bash scripts/read-package-version.sh)"
echo "release Directory.Build.props Version=${VERSION}"
bash scripts/ensure-git-tag.sh "${VERSION}"
bash scripts/pack-release.sh
bash scripts/nuget-push-download.sh nupkgs

43
scripts/woodpecker-verify.sh Executable file
View File

@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Library quality gate: analyzers on, tests, coverlet >= 90% on Robotico.Nexus.Client.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "${ROOT}"
TEST_PROJ="tests/Robotico.Nexus.Client.Tests/Robotico.Nexus.Client.Tests.csproj"
COVERAGE_DIR="${ROOT}/coverage"
MIN_LINE_RATE="${COVERAGE_MIN_LINE_RATE:-0.90}"
test -f Directory.Build.props || { echo "MUST - Directory.Build.props required"; exit 1; }
test -f tests/coverlet.runsettings || { echo "MUST - tests/coverlet.runsettings required"; exit 1; }
rm -rf "${COVERAGE_DIR}"
mkdir -p "${COVERAGE_DIR}"
echo "woodpecker-verify: restore/build/test with analyzers (Library 10 bar)"
dotnet restore "${TEST_PROJ}" -p:NuGetAudit=false
dotnet build "${TEST_PROJ}" -c Release --no-restore -p:NuGetAudit=false
dotnet test "${TEST_PROJ}" -c Release --no-build --verbosity minimal -p:NuGetAudit=false \
--collect:"XPlat Code Coverage" --results-directory "${COVERAGE_DIR}" \
--settings tests/coverlet.runsettings
COV_FILE="$(find "${COVERAGE_DIR}" -name 'coverage.cobertura.xml' 2>/dev/null | head -1 || true)"
if [[ -z "${COV_FILE}" ]]; then
echo "error: no coverage.cobertura.xml under ${COVERAGE_DIR}" >&2
exit 1
fi
python3 - "${COV_FILE}" "${MIN_LINE_RATE}" <<'PY'
import sys
import xml.etree.ElementTree as ET
path, minimum = sys.argv[1], float(sys.argv[2])
root = ET.parse(path).getroot()
rate = float(root.attrib.get("line-rate", "0"))
print(f"Line coverage (Robotico.Nexus.Client): {rate:.4f} (minimum {minimum:.2f})")
if rate + 1e-9 < minimum:
sys.exit(f"Coverage below {minimum:.0%}")
PY
echo "woodpecker-verify: OK"

View File

@ -0,0 +1,89 @@
using Grpc.Core;
using Grpc.Net.Client;
using Mishima.Nexus.Wire.V1;
namespace Robotico.Nexus.Client.Document;
/// <summary>
/// Thin document-plane façade: liveness (<see cref="CheckHealth"/>) plus single-document
/// upsert/get/delete — enough for MVP smoke usage. This is <b>not</b> a full <c>IMishimaDatabase</c>
/// façade (no collections, indexes, batches, or change feed); use the generated
/// <see cref="Mishima.Nexus.Wire.V1.NexusDocumentEngineService.NexusDocumentEngineServiceClient"/>
/// directly (via <see cref="NexusClient.CreateDocumentWireClient"/>) for the full RPC surface.
/// </summary>
public sealed class NexusDocumentClient
{
private readonly NexusDocumentEngineService.NexusDocumentEngineServiceClient _client;
private readonly string _databaseName;
private readonly string? _bearerToken;
internal NexusDocumentClient(GrpcChannel channel, string databaseName, string? bearerToken)
{
_client = new NexusDocumentEngineService.NexusDocumentEngineServiceClient(channel);
_databaseName = databaseName;
_bearerToken = bearerToken;
}
/// <summary>Calls <c>NexusDocumentEngineService.Ping</c>.</summary>
public Robotico.Result.Result CheckHealth() => Invoke(() =>
{
DocumentPingResponse response = _client.Ping(new DocumentPingRequest(), Metadata());
return NexusWireStatus.ToResult(response.Status);
});
/// <summary>Inserts or replaces a document by id within a collection.</summary>
public Robotico.Result.Result UpsertDocument(string collectionName, string documentId, string jsonBody) => Invoke(() =>
{
DocumentUpsertRequest request = new()
{
CollectionName = collectionName,
DocumentId = documentId,
JsonBody = jsonBody,
};
DocumentUpsertResponse response = _client.UpsertDocument(request, Metadata());
return NexusWireStatus.ToResult(response.Status);
});
/// <summary>Reads a document's JSON body, or <see langword="null"/> when not found.</summary>
public Robotico.Result.Result<string?> GetDocument(string collectionName, string documentId) => InvokeGeneric(() =>
{
DocumentGetRequest request = new() { CollectionName = collectionName, DocumentId = documentId };
DocumentGetResponse response = _client.GetDocument(request, Metadata());
string? body = response.Found ? response.JsonBody : null;
return NexusWireStatus.ToResult(response.Status, body);
});
/// <summary>Deletes a document by id when present (idempotent).</summary>
public Robotico.Result.Result DeleteDocument(string collectionName, string documentId) => Invoke(() =>
{
DocumentDeleteRequest request = new() { CollectionName = collectionName, DocumentId = documentId };
DocumentDeleteResponse response = _client.DeleteDocument(request, Metadata());
return NexusWireStatus.ToResult(response.Status);
});
private Metadata Metadata() => NexusCallMetadata.Build(_databaseName, _bearerToken);
private static Robotico.Result.Result Invoke(Func<Robotico.Result.Result> call)
{
try
{
return call();
}
catch (RpcException ex)
{
return Robotico.Result.Result.Error(NexusWireStatus.FromException(ex));
}
}
private static Robotico.Result.Result<T> InvokeGeneric<T>(Func<Robotico.Result.Result<T>> call)
{
try
{
return call();
}
catch (RpcException ex)
{
return Robotico.Result.Result.Error<T>(NexusWireStatus.FromException(ex));
}
}
}

View File

@ -0,0 +1,310 @@
using Grpc.Core;
using Mishima.Nexus.Wire.V1;
using Robotico.Graph;
using Robotico.Result.Errors;
namespace Robotico.Nexus.Client.Graph;
/// <summary>
/// <see cref="IGraphSession"/> bound to one Nexus logical graph database. Does not expose
/// Cypher/Gremlin/SPARQL/Vacuum — those stay on Nexus language packs, out of this port.
/// </summary>
public sealed class NexusGraphSession : IGraphSession
{
private readonly NexusGraphEngineService.NexusGraphEngineServiceClient _client;
private readonly string _databaseName;
private readonly string? _bearerToken;
private bool _disposed;
internal NexusGraphSession(NexusGraphEngineService.NexusGraphEngineServiceClient client, string databaseName, string? bearerToken)
{
_client = client;
_databaseName = databaseName;
_bearerToken = bearerToken;
}
/// <inheritdoc />
public Robotico.Result.Result RegisterLabel(string labelName)
{
Robotico.Result.Result disposed = EnsureNotDisposed();
if (disposed.IsError(out IError? disposedError))
{
return Robotico.Result.Result.Error(disposedError!);
}
Robotico.Result.Result<string> name = RequireName(labelName, "label");
if (!name.IsSuccess(out string? trimmed, out IError? nameError))
{
return Robotico.Result.Result.Error(nameError!);
}
return Invoke(() => NexusWireStatus.ToResult(
_client.RegisterLabel(new GraphRegisterLabelRequest { LabelName = trimmed }, Metadata()).Status));
}
/// <inheritdoc />
public Robotico.Result.Result RegisterEdgeType(string edgeTypeName)
{
Robotico.Result.Result disposed = EnsureNotDisposed();
if (disposed.IsError(out IError? disposedError))
{
return Robotico.Result.Result.Error(disposedError!);
}
Robotico.Result.Result<string> name = RequireName(edgeTypeName, "edge type");
if (!name.IsSuccess(out string? trimmed, out IError? nameError))
{
return Robotico.Result.Result.Error(nameError!);
}
return Invoke(() => NexusWireStatus.ToResult(
_client.RegisterEdgeType(new GraphRegisterEdgeTypeRequest { EdgeTypeName = trimmed }, Metadata()).Status));
}
/// <inheritdoc />
public Robotico.Result.Result<ulong> UpsertVertex(ulong vertexId, IReadOnlyList<string> labelNames)
{
Robotico.Result.Result disposed = EnsureNotDisposed();
if (disposed.IsError(out IError? disposedError))
{
return Robotico.Result.Result.Error<ulong>(disposedError!);
}
if (labelNames is null || labelNames.Count is 0)
{
return Robotico.Result.Result.Error<ulong>(GraphErrors.Error("At least one label name is required.", GraphErrors.InvalidArgument));
}
GraphUpsertVertexRequest request = new() { VertexId = vertexId };
request.LabelNames.AddRange(labelNames);
return InvokeGeneric(() =>
{
GraphUpsertVertexResponse response = _client.UpsertVertex(request, Metadata());
return NexusWireStatus.ToResult(response.Status, response.CommittedTxnId);
});
}
/// <inheritdoc />
public Robotico.Result.Result<ulong> DeleteVertex(ulong vertexId)
{
Robotico.Result.Result disposed = EnsureNotDisposed();
if (disposed.IsError(out IError? disposedError))
{
return Robotico.Result.Result.Error<ulong>(disposedError!);
}
return InvokeGeneric(() =>
{
GraphDeleteVertexResponse response = _client.DeleteVertex(new GraphDeleteVertexRequest { VertexId = vertexId }, Metadata());
return NexusWireStatus.ToResult(response.Status, response.CommittedTxnId);
});
}
/// <inheritdoc />
public Robotico.Result.Result<GraphVertex> GetVertex(ulong vertexId)
{
Robotico.Result.Result disposed = EnsureNotDisposed();
if (disposed.IsError(out IError? disposedError))
{
return Robotico.Result.Result.Error<GraphVertex>(disposedError!);
}
return InvokeGeneric(() =>
{
GraphGetVertexResponse response = _client.GetVertex(new GraphGetVertexRequest { VertexId = vertexId }, Metadata());
return NexusWireStatus.ToResult(response.Status, new GraphVertex(response.VertexId, [.. response.LabelNames]));
});
}
/// <inheritdoc />
public Robotico.Result.Result<ulong> UpsertEdge(ulong sourceVertexId, ulong targetVertexId, string edgeTypeName)
{
Robotico.Result.Result disposed = EnsureNotDisposed();
if (disposed.IsError(out IError? disposedError))
{
return Robotico.Result.Result.Error<ulong>(disposedError!);
}
Robotico.Result.Result<string> name = RequireName(edgeTypeName, "edge type");
if (!name.IsSuccess(out string? trimmed, out IError? nameError))
{
return Robotico.Result.Result.Error<ulong>(nameError!);
}
return InvokeGeneric(() =>
{
GraphUpsertEdgeRequest request = new()
{
SourceVertexId = sourceVertexId,
TargetVertexId = targetVertexId,
EdgeTypeName = trimmed,
};
GraphUpsertEdgeResponse response = _client.UpsertEdge(request, Metadata());
return NexusWireStatus.ToResult(response.Status, response.CommittedTxnId);
});
}
/// <inheritdoc />
public Robotico.Result.Result<ulong> DeleteEdge(ulong sourceVertexId, ulong targetVertexId, string edgeTypeName)
{
Robotico.Result.Result disposed = EnsureNotDisposed();
if (disposed.IsError(out IError? disposedError))
{
return Robotico.Result.Result.Error<ulong>(disposedError!);
}
Robotico.Result.Result<string> name = RequireName(edgeTypeName, "edge type");
if (!name.IsSuccess(out string? trimmed, out IError? nameError))
{
return Robotico.Result.Result.Error<ulong>(nameError!);
}
return InvokeGeneric(() =>
{
GraphDeleteEdgeRequest request = new()
{
SourceVertexId = sourceVertexId,
TargetVertexId = targetVertexId,
EdgeTypeName = trimmed,
};
GraphDeleteEdgeResponse response = _client.DeleteEdge(request, Metadata());
return NexusWireStatus.ToResult(response.Status, response.CommittedTxnId);
});
}
/// <inheritdoc />
public Robotico.Result.Result<GraphEdge> GetEdge(ulong sourceVertexId, ulong targetVertexId, string edgeTypeName)
{
Robotico.Result.Result disposed = EnsureNotDisposed();
if (disposed.IsError(out IError? disposedError))
{
return Robotico.Result.Result.Error<GraphEdge>(disposedError!);
}
Robotico.Result.Result<string> name = RequireName(edgeTypeName, "edge type");
if (!name.IsSuccess(out string? trimmed, out IError? nameError))
{
return Robotico.Result.Result.Error<GraphEdge>(nameError!);
}
return InvokeGeneric(() =>
{
GraphGetEdgeRequest request = new()
{
SourceVertexId = sourceVertexId,
TargetVertexId = targetVertexId,
EdgeTypeName = trimmed,
};
GraphGetEdgeResponse response = _client.GetEdge(request, Metadata());
return NexusWireStatus.ToResult(
response.Status,
new GraphEdge(response.SourceVertexId, response.TargetVertexId, response.EdgeTypeName));
});
}
/// <inheritdoc />
public Robotico.Result.Result<GraphNeighborhood> GetNeighborhood(ulong startVertexId, int? maxDepth = null, string? edgeTypeName = null)
{
Robotico.Result.Result disposed = EnsureNotDisposed();
if (disposed.IsError(out IError? disposedError))
{
return Robotico.Result.Result.Error<GraphNeighborhood>(disposedError!);
}
return InvokeGeneric(() =>
{
GraphBreadthFirstRequest request = new() { StartVertexId = startVertexId };
if (maxDepth.HasValue)
{
request.MaxDepth = maxDepth.Value;
}
if (!string.IsNullOrWhiteSpace(edgeTypeName))
{
request.EdgeTypeName = edgeTypeName.Trim();
}
GraphBreadthFirstResponse response = _client.BreadthFirst(request, Metadata());
Dictionary<ulong, int> distances = new(response.DistanceByVertex.Count);
foreach (KeyValuePair<ulong, int> pair in response.DistanceByVertex)
{
distances[pair.Key] = pair.Value;
}
return NexusWireStatus.ToResult(
response.Status,
new GraphNeighborhood(startVertexId, [.. response.VisitOrder], distances));
});
}
/// <inheritdoc />
public Robotico.Result.Result<GraphVertexPage> ListVerticesByLabel(string labelName, int skip = 0, int limit = 25)
{
Robotico.Result.Result disposed = EnsureNotDisposed();
if (disposed.IsError(out IError? disposedError))
{
return Robotico.Result.Result.Error<GraphVertexPage>(disposedError!);
}
Robotico.Result.Result<string> name = RequireName(labelName, "label");
if (!name.IsSuccess(out string? trimmed, out IError? nameError))
{
return Robotico.Result.Result.Error<GraphVertexPage>(nameError!);
}
(int pageSkip, int pageLimit) = GraphPaging.Normalize(skip, limit);
return InvokeGeneric(() =>
{
GraphListVerticesByLabelRequest request = new() { LabelName = trimmed, Skip = pageSkip, Limit = pageLimit };
GraphListVerticesByLabelResponse response = _client.ListVerticesByLabel(request, Metadata());
GraphVertex[] items = [.. response.Vertices.Select(static v => new GraphVertex(v.VertexId, [.. v.LabelNames]))];
return NexusWireStatus.ToResult(
response.Status,
new GraphVertexPage(response.LabelName, response.TotalMatching, response.Skip, response.Limit, items));
});
}
/// <inheritdoc />
public void Dispose() => _disposed = true;
private Metadata Metadata() => NexusCallMetadata.Build(_databaseName, _bearerToken);
private Robotico.Result.Result EnsureNotDisposed() =>
_disposed
? Robotico.Result.Result.Error(NexusErrors.Error("Graph session is disposed.", NexusErrors.Disposed))
: Robotico.Result.Result.Success();
private static Robotico.Result.Result<string> RequireName(string value, string kind)
{
if (string.IsNullOrWhiteSpace(value))
{
return Robotico.Result.Result.Error<string>(GraphErrors.Error($"{kind} name is required.", GraphErrors.InvalidArgument));
}
return Robotico.Result.Result.Success(value.Trim());
}
private static Robotico.Result.Result Invoke(Func<Robotico.Result.Result> call)
{
try
{
return call();
}
catch (RpcException ex)
{
return Robotico.Result.Result.Error(NexusWireStatus.FromException(ex));
}
}
private static Robotico.Result.Result<T> InvokeGeneric<T>(Func<Robotico.Result.Result<T>> call)
{
try
{
return call();
}
catch (RpcException ex)
{
return Robotico.Result.Result.Error<T>(NexusWireStatus.FromException(ex));
}
}
}

View File

@ -0,0 +1,147 @@
using System.Diagnostics.CodeAnalysis;
using Grpc.Core;
using Grpc.Net.Client;
using Mishima.Nexus.Wire.V1;
using Robotico.Graph;
using Robotico.Result.Errors;
namespace Robotico.Nexus.Client.Graph;
/// <summary>
/// <see cref="IGraphStore"/> over the Nexus graph plane gRPC service. Per POLA, the
/// <c>databaseFilePath</c> argument on every method is the <b>logical database name</b> (Nexus
/// <c>x-nexus-database</c> metadata value), not a filesystem path — see
/// docs/adr/0001-nexus-client-monolith.adoc.
/// </summary>
/// <remarks>
/// <c>Ping</c> is an engine-liveness probe on the Nexus graph host, not a per-database
/// existence check, so <see cref="Create"/> will typically report <see cref="NexusErrors.AlreadyExists"/>
/// once the engine is reachable. Use <see cref="OpenOrCreate"/> for greenfield databases — the Nexus
/// host provisions the logical database lazily on the first RPC that touches its state.
/// </remarks>
public sealed class NexusGraphStore : IGraphStore
{
private readonly NexusGraphEngineService.NexusGraphEngineServiceClient _client;
private readonly string? _bearerToken;
private readonly string _defaultDatabaseName;
internal NexusGraphStore(GrpcChannel channel, string? bearerToken, string defaultDatabaseName)
{
_client = new NexusGraphEngineService.NexusGraphEngineServiceClient(channel);
_bearerToken = bearerToken;
_defaultDatabaseName = defaultDatabaseName;
}
/// <inheritdoc />
public Robotico.Result.Result<IGraphSession> Create(
string databaseFilePath,
IReadOnlyList<string>? initialLabels = null,
IReadOnlyList<string>? initialEdgeTypes = null)
{
Robotico.Result.Result<string> nameResult = ResolveName(databaseFilePath);
if (!nameResult.IsSuccess(out string? name, out IError? nameError))
{
return Robotico.Result.Result.Error<IGraphSession>(nameError!);
}
Robotico.Result.Result<bool> reachable = PingReachable(name!);
if (!reachable.IsSuccess(out bool alreadyUp, out IError? pingError))
{
return Robotico.Result.Result.Error<IGraphSession>(pingError!);
}
if (alreadyUp)
{
return Robotico.Result.Result.Error<IGraphSession>(NexusErrors.Error(
$"Graph database '{name}' already answers to Ping. Nexus Ping is engine-level, not " +
"per-database, so Create cannot distinguish a fresh logical database — use OpenOrCreate.",
NexusErrors.AlreadyExists));
}
Robotico.Result.Result<IGraphSession> opened = OpenCore(name!);
if (!opened.IsSuccess(out IGraphSession? session, out IError? openError))
{
return Robotico.Result.Result.Error<IGraphSession>(openError!);
}
try
{
foreach (string label in initialLabels ?? [])
{
Robotico.Result.Result registered = session.RegisterLabel(label);
if (registered.IsError(out IError? labelError))
{
return Robotico.Result.Result.Error<IGraphSession>(labelError!);
}
}
foreach (string edgeType in initialEdgeTypes ?? [])
{
Robotico.Result.Result registered = session.RegisterEdgeType(edgeType);
if (registered.IsError(out IError? edgeError))
{
return Robotico.Result.Result.Error<IGraphSession>(edgeError!);
}
}
IGraphSession created = session;
session = null;
return Robotico.Result.Result.Success(created);
}
finally
{
session?.Dispose();
}
}
/// <inheritdoc />
public Robotico.Result.Result<IGraphSession> Open(string databaseFilePath)
{
Robotico.Result.Result<string> nameResult = ResolveName(databaseFilePath);
return nameResult.IsSuccess(out string? name, out IError? nameError)
? OpenCore(name!)
: Robotico.Result.Result.Error<IGraphSession>(nameError!);
}
/// <inheritdoc />
public Robotico.Result.Result<IGraphSession> OpenOrCreate(string databaseFilePath) => Open(databaseFilePath);
[SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", Justification = "Ownership of the returned NexusGraphSession transfers to the caller via Result<IGraphSession>.Success; the caller is responsible for disposing it.")]
private Robotico.Result.Result<IGraphSession> OpenCore(string name)
{
Robotico.Result.Result<bool> reachable = PingReachable(name);
if (!reachable.IsSuccess(out bool ok, out IError? pingError))
{
return Robotico.Result.Result.Error<IGraphSession>(pingError!);
}
return ok
? Robotico.Result.Result.Success<IGraphSession>(new NexusGraphSession(_client, name, _bearerToken))
: Robotico.Result.Result.Error<IGraphSession>(NexusErrors.Error($"Graph engine ping failed for database '{name}'.", NexusErrors.Unavailable));
}
private Robotico.Result.Result<bool> PingReachable(string name)
{
try
{
GraphPingResponse response = _client.Ping(new GraphPingRequest(), NexusCallMetadata.Build(name, _bearerToken));
return Robotico.Result.Result.Success(NexusWireStatus.IsOk(response.Status));
}
catch (RpcException ex)
{
return Robotico.Result.Result.Error<bool>(NexusWireStatus.FromException(ex));
}
}
private Robotico.Result.Result<string> ResolveName(string databaseFilePath)
{
if (string.IsNullOrWhiteSpace(databaseFilePath))
{
return string.IsNullOrWhiteSpace(_defaultDatabaseName)
? Robotico.Result.Result.Error<string>(NexusErrors.Error("Logical database name is required.", NexusErrors.InvalidArgument))
: Robotico.Result.Result.Success(_defaultDatabaseName);
}
return Robotico.Result.Result.Success(databaseFilePath.Trim());
}
}

View File

@ -0,0 +1,25 @@
using Grpc.Core;
namespace Robotico.Nexus.Client;
/// <summary>Builds per-call gRPC metadata (logical database + optional bearer) for wire calls.</summary>
internal static class NexusCallMetadata
{
/// <summary>gRPC metadata key for logical database selection (matches Nexus server-side convention).</summary>
internal const string DatabaseKey = "x-nexus-database";
/// <summary>Builds headers for a single RPC bound to <paramref name="databaseName"/>.</summary>
internal static Metadata Build(string databaseName, string? bearerToken)
{
Metadata metadata = new() { { DatabaseKey, databaseName } };
if (!string.IsNullOrWhiteSpace(bearerToken))
{
string value = bearerToken.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)
? bearerToken
: "Bearer " + bearerToken;
metadata.Add("Authorization", value);
}
return metadata;
}
}

View File

@ -0,0 +1,185 @@
using System.Diagnostics.CodeAnalysis;
using Grpc.Core;
using Grpc.Net.Client;
using Mishima.Nexus.Wire.V1;
using Robotico.Graph;
using Robotico.Nexus.Client.Document;
using Robotico.Nexus.Client.Graph;
using Robotico.Nexus.Client.Relational;
using Robotico.Nexus.Client.Time;
using Robotico.Result.Errors;
using Robotico.Time;
namespace Robotico.Nexus.Client;
/// <summary>
/// Connects to a Nexus wire endpoint and exposes plane facades: <see cref="Graph"/> / <see cref="Time"/>
/// implement <c>Robotico.Graph</c> / <c>Robotico.Time</c> ports over gRPC, and
/// <see cref="CreateDocumentClient"/> / <see cref="CreateRelationalClient"/> return thin document/relational
/// façades (MVP — no full <c>IMishimaDatabase</c> façade; use <see cref="CreateDocumentWireClient"/> /
/// <see cref="CreateRelationalWireClient"/> for the full generated Wire.V1 RPC surface).
/// One connection is bound to a single scheme + logical database from the connection URI; open a
/// second <see cref="NexusClient"/> to talk to another plane or database.
/// </summary>
public sealed class NexusClient : IAsyncDisposable, IDisposable
{
private readonly GrpcChannel _channel;
private readonly string? _bearerToken;
private bool _disposed;
private NexusClient(GrpcChannel channel, NexusConnectionUriParts parsed, string boundDatabase, string? bearerToken)
{
_channel = channel;
_bearerToken = bearerToken;
Scheme = parsed.Scheme;
Database = boundDatabase;
Graph = new NexusGraphStore(channel, bearerToken, boundDatabase);
Time = new NexusTimeStore(channel, bearerToken, boundDatabase);
}
/// <summary>Connection scheme (<see cref="NexusUriSchemes"/>) resolved at connect time.</summary>
public string Scheme { get; }
/// <summary>Logical database bound at connect time (URI path segment, or the scheme's canonical default).</summary>
public string Database { get; }
/// <summary>
/// Graph plane store bound to <see cref="Database"/> when <see cref="Scheme"/> is
/// <see cref="NexusUriSchemes.Graph"/>; usable against any logical database name via
/// <see cref="IGraphStore.Open"/> regardless of scheme.
/// </summary>
public IGraphStore Graph { get; }
/// <summary>
/// Time plane store bound to <see cref="Database"/> when <see cref="Scheme"/> is
/// <see cref="NexusUriSchemes.Time"/>; usable against any logical database name via
/// <see cref="ITimeStore.Open"/> regardless of scheme.
/// </summary>
public ITimeStore Time { get; }
/// <summary>Connects to a Nexus wire endpoint. Validates the URI and opens a gRPC channel eagerly.</summary>
[SuppressMessage("Design", "CA1054:URI-like parameters should not be strings", Justification = "Nexus connection strings use custom nexus+* schemes and are the documented public API surface (mirrors ADO.NET / MongoDB connection-string ergonomics); a System.Uri overload adds no value here.")]
[SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", Justification = "Ownership of the returned NexusClient (and its channel) transfers to the caller via Result<NexusClient>.Success; the caller is responsible for disposing it.")]
public static Robotico.Result.Result<NexusClient> Connect(string connectionUri, NexusClientOptions? options = null)
{
Robotico.Result.Result<NexusConnectionUriParts> parsedResult = NexusConnectionUri.TryParse(connectionUri);
if (!parsedResult.IsSuccess(out NexusConnectionUriParts? parsed, out IError? parseError))
{
return Robotico.Result.Result.Error<NexusClient>(parseError!);
}
options ??= new NexusClientOptions();
NexusTlsMaterial? tls = options.ResolveTls();
bool useHttps = options.ResolveUseHttps(tls);
Robotico.Result.Result<GrpcChannel> channelResult = NexusGrpcChannelFactory.CreateChannel(parsed!, tls, useHttps);
if (!channelResult.IsSuccess(out GrpcChannel? channel, out IError? channelError))
{
return Robotico.Result.Result.Error<NexusClient>(channelError!);
}
string database = string.IsNullOrWhiteSpace(parsed!.Database)
? NexusConnectionUri.DefaultDatabase(parsed.Scheme)
: parsed.Database!;
return Robotico.Result.Result.Success(new NexusClient(channel!, parsed, database, options.ResolveBearerToken()));
}
/// <summary>Calls <c>NexusHealthService.Check</c> and succeeds only when the server reports ready.</summary>
public Robotico.Result.Result CheckHealth()
{
Robotico.Result.Result disposed = EnsureNotDisposed();
if (!disposed.IsSuccess())
{
return disposed;
}
NexusHealthService.NexusHealthServiceClient client = new(_channel);
try
{
HealthCheckResponse response = client.Check(new HealthCheckRequest(), NexusCallMetadata.Build(Database, _bearerToken));
return response.Ready
? Robotico.Result.Result.Success()
: Robotico.Result.Result.Error(NexusErrors.Error("Nexus health check reports not ready.", NexusErrors.Unavailable));
}
catch (RpcException ex)
{
return Robotico.Result.Result.Error(NexusWireStatus.FromException(ex));
}
}
/// <summary>Creates a thin document-plane façade (Ping + single-document upsert/get/delete) bound to this channel.</summary>
public Robotico.Result.Result<NexusDocumentClient> CreateDocumentClient()
{
Robotico.Result.Result disposed = EnsureNotDisposed();
return disposed.IsSuccess()
? Robotico.Result.Result.Success(new NexusDocumentClient(_channel, Database, _bearerToken))
: Robotico.Result.Result.Error<NexusDocumentClient>(AsError(disposed));
}
/// <summary>Creates a thin relational-plane façade (Ping only) bound to this channel.</summary>
public Robotico.Result.Result<NexusRelationalClient> CreateRelationalClient()
{
Robotico.Result.Result disposed = EnsureNotDisposed();
return disposed.IsSuccess()
? Robotico.Result.Result.Success(new NexusRelationalClient(_channel, Database, _bearerToken))
: Robotico.Result.Result.Error<NexusRelationalClient>(AsError(disposed));
}
/// <summary>Creates the generated document-plane Wire.V1 client bound to this channel (full RPC surface).</summary>
public Robotico.Result.Result<NexusDocumentEngineService.NexusDocumentEngineServiceClient> CreateDocumentWireClient()
{
Robotico.Result.Result disposed = EnsureNotDisposed();
return disposed.IsSuccess()
? Robotico.Result.Result.Success(new NexusDocumentEngineService.NexusDocumentEngineServiceClient(_channel))
: Robotico.Result.Result.Error<NexusDocumentEngineService.NexusDocumentEngineServiceClient>(AsError(disposed));
}
/// <summary>Creates the generated relational-plane Wire.V1 client bound to this channel (full RPC surface).</summary>
public Robotico.Result.Result<NexusRelationalEngineService.NexusRelationalEngineServiceClient> CreateRelationalWireClient()
{
Robotico.Result.Result disposed = EnsureNotDisposed();
return disposed.IsSuccess()
? Robotico.Result.Result.Success(new NexusRelationalEngineService.NexusRelationalEngineServiceClient(_channel))
: Robotico.Result.Result.Error<NexusRelationalEngineService.NexusRelationalEngineServiceClient>(AsError(disposed));
}
/// <summary>Metadata headers a caller can attach to a manual RPC (document/relational clients) for this database.</summary>
public Metadata BuildCallMetadata(string? databaseNameOverride = null) =>
NexusCallMetadata.Build(string.IsNullOrWhiteSpace(databaseNameOverride) ? Database : databaseNameOverride, _bearerToken);
/// <inheritdoc />
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
_channel.Dispose();
}
/// <inheritdoc />
public ValueTask DisposeAsync()
{
if (_disposed)
{
return ValueTask.CompletedTask;
}
_disposed = true;
_channel.Dispose();
return ValueTask.CompletedTask;
}
private Robotico.Result.Result EnsureNotDisposed() =>
_disposed
? Robotico.Result.Result.Error(NexusErrors.Error("NexusClient is disposed.", NexusErrors.Disposed))
: Robotico.Result.Result.Success();
private static IError AsError(Robotico.Result.Result failed)
{
failed.IsError(out IError? error);
return error ?? NexusErrors.Error("NexusClient operation failed.");
}
}

View File

@ -0,0 +1,49 @@
namespace Robotico.Nexus.Client;
/// <summary>
/// Explicit connection options for <see cref="NexusClient.Connect"/>. When omitted, TLS material
/// falls back to <see cref="NexusTlsMaterial.TryFromEnvironment"/> and the bearer token falls back
/// to the <c>NEXUS_WIRE_BEARER</c> / <c>NEXUS_WIRE_DEFAULT_BEARER</c> environment variables (same
/// names as <c>Mishima.Nexus.Wire.Clients</c> — POLA).
/// </summary>
public sealed class NexusClientOptions
{
/// <summary>Explicit TLS/mTLS material; overrides environment discovery when set.</summary>
public NexusTlsMaterial? Tls { get; init; }
/// <summary>
/// Forces the transport scheme when <see cref="Tls"/> is <see langword="null"/>: <see langword="true"/> for
/// <c>https://</c>, <see langword="false"/> for plaintext <c>http://</c> (h2c, dev/loopback only).
/// Defaults to plaintext when unset and no TLS material is available.
/// </summary>
public bool? UseHttps { get; init; }
/// <summary>Bearer token attached as an <c>Authorization</c> header on every call.</summary>
public string? BearerToken { get; init; }
/// <summary>Resolves TLS material: explicit option first, else process environment.</summary>
internal NexusTlsMaterial? ResolveTls() => Tls ?? NexusTlsMaterial.TryFromEnvironment();
/// <summary>Resolves the bearer token: explicit option first, else process environment.</summary>
internal string? ResolveBearerToken() =>
BearerToken
?? Environment.GetEnvironmentVariable("NEXUS_WIRE_BEARER")
?? Environment.GetEnvironmentVariable("NEXUS_WIRE_DEFAULT_BEARER");
/// <summary>Resolves whether to dial https, honoring TLS material, explicit option, then env <c>NEXUS_TLS_MODE</c>.</summary>
internal bool ResolveUseHttps(NexusTlsMaterial? tls)
{
if (tls is not null)
{
return tls.UseHttps;
}
if (UseHttps.HasValue)
{
return UseHttps.Value;
}
string? mode = Environment.GetEnvironmentVariable("NEXUS_TLS_MODE");
return string.Equals(mode, "https", StringComparison.OrdinalIgnoreCase);
}
}

View File

@ -0,0 +1,80 @@
using System.Diagnostics.CodeAnalysis;
namespace Robotico.Nexus.Client;
/// <summary>
/// Parses Robotico.Nexus.Client connection URIs: <c>nexus+graph://</c>, <c>nexus+time://</c>,
/// <c>nexus+document://</c>, <c>nexus+relational://</c>. Duplicated (not referenced) from
/// <c>Mishima.Nexus.Abstractions.NexusConnectionUri</c> so this package never pulls the Nexus
/// F# config / domain assemblies — see docs/adr/0001-nexus-client-monolith.adoc.
/// </summary>
public static class NexusConnectionUri
{
/// <summary>Parses a Nexus client connection URI.</summary>
[SuppressMessage("Design", "CA1054:URI-like parameters should not be strings", Justification = "Nexus connection strings use custom nexus+* schemes and are the documented public API surface (mirrors ADO.NET / MongoDB connection-string ergonomics); a System.Uri overload adds no value here.")]
public static Robotico.Result.Result<NexusConnectionUriParts> TryParse(string connectionUri)
{
if (string.IsNullOrWhiteSpace(connectionUri))
{
return Robotico.Result.Result.Error<NexusConnectionUriParts>(NexusErrors.Error("Connection URI is required.", NexusErrors.UriInvalid));
}
if (!Uri.TryCreate(connectionUri, UriKind.Absolute, out Uri? uri))
{
return Robotico.Result.Result.Error<NexusConnectionUriParts>(NexusErrors.Error($"Malformed connection URI '{connectionUri}'.", NexusErrors.UriInvalid));
}
string scheme = uri.Scheme;
if (string.Equals(scheme, NexusUriSchemes.Sql, StringComparison.Ordinal))
{
return Robotico.Result.Result.Error<NexusConnectionUriParts>(NexusErrors.Error(
$"Scheme '{NexusUriSchemes.Sql}' is not supported by Robotico.Nexus.Client — use Mishima.Nexus.Sql directly.",
NexusErrors.UnsupportedScheme));
}
bool supported = scheme switch
{
NexusUriSchemes.Graph => true,
NexusUriSchemes.Time => true,
NexusUriSchemes.Document => true,
NexusUriSchemes.Relational => true,
_ => false,
};
if (!supported)
{
return Robotico.Result.Result.Error<NexusConnectionUriParts>(NexusErrors.Error($"Unsupported connection scheme '{scheme}'.", NexusErrors.UnsupportedScheme));
}
// Explicit port required — never invent a default so operators cannot silently hit the wrong endpoint.
if (uri.IsDefaultPort || uri.Port is 0)
{
return Robotico.Result.Result.Error<NexusConnectionUriParts>(NexusErrors.Error(
"Connection URI must include an explicit port (e.g. nexus+graph://host:50051/pathways).",
NexusErrors.UriInvalid));
}
if (string.IsNullOrWhiteSpace(uri.Host))
{
return Robotico.Result.Result.Error<NexusConnectionUriParts>(NexusErrors.Error("Connection URI host is required.", NexusErrors.UriInvalid));
}
string? database = uri.AbsolutePath switch
{
"" => null,
"/" => null,
_ => uri.AbsolutePath.TrimStart('/'),
};
return Robotico.Result.Result.Success(new NexusConnectionUriParts(scheme, uri.Host, uri.Port, database));
}
/// <summary>Canonical default logical database name per scheme when the URI path is empty.</summary>
public static string DefaultDatabase(string scheme) => scheme switch
{
NexusUriSchemes.Graph => "pathways",
NexusUriSchemes.Time => "telemetry",
NexusUriSchemes.Document => "catalog",
NexusUriSchemes.Relational => "ledger",
_ => "primary",
};
}

View File

@ -0,0 +1,8 @@
namespace Robotico.Nexus.Client;
/// <summary>Parsed <see cref="NexusConnectionUri"/> connection target.</summary>
/// <param name="Scheme">One of the <see cref="NexusUriSchemes"/> values.</param>
/// <param name="Host">Target host name or address.</param>
/// <param name="Port">Explicit target port (never inferred).</param>
/// <param name="Database">Logical database name from the URI path, or <see langword="null"/> when omitted.</param>
public sealed record NexusConnectionUriParts(string Scheme, string Host, int Port, string? Database);

View File

@ -0,0 +1,46 @@
using Robotico.Result.Errors;
namespace Robotico.Nexus.Client;
/// <summary>Stable error codes for <see cref="Robotico.Nexus.Client"/>.</summary>
public static class NexusErrors
{
/// <summary>General Nexus client failure.</summary>
public const string Code = "NXS_NEXUS";
/// <summary>Connection URI failed to parse or uses an unsupported form.</summary>
public const string UriInvalid = "NXS_URI_INVALID";
/// <summary>Connection URI scheme is recognized but not supported by this client.</summary>
public const string UnsupportedScheme = "NXS_URI_UNSUPPORTED_SCHEME";
/// <summary>The gRPC channel could not be created (TLS material, address, or transport failure).</summary>
public const string ConnectFailed = "NXS_CONNECT_FAILED";
/// <summary>TLS/mTLS material was rejected or missing required files.</summary>
public const string TlsRejected = "NXS_TLS_REJECTED";
/// <summary>Create was called for a database that already answers to Ping.</summary>
public const string AlreadyExists = "NXS_ALREADY_EXISTS";
/// <summary>Requested resource was not found on the remote engine.</summary>
public const string NotFound = "NXS_NOT_FOUND";
/// <summary>Invalid argument (empty name, bad range, unknown wire type).</summary>
public const string InvalidArgument = "NXS_INVALID_ARGUMENT";
/// <summary>Operation attempted on a disposed client/session.</summary>
public const string Disposed = "NXS_DISPOSED";
/// <summary>Remote engine is unreachable or the call exceeded its deadline.</summary>
public const string Unavailable = "NXS_UNAVAILABLE";
/// <summary>Generic wire/gRPC failure not covered by a more specific code.</summary>
public const string WireFailed = "NXS_WIRE_FAILED";
/// <summary>Creates a <see cref="SimpleError"/> with <see cref="Code"/>.</summary>
public static SimpleError Error(string message) => new(message, Code);
/// <summary>Creates a <see cref="SimpleError"/> with an explicit code.</summary>
public static SimpleError Error(string message, string code) => new(message, code);
}

View File

@ -0,0 +1,69 @@
using System.Diagnostics.CodeAnalysis;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using Grpc.Net.Client;
namespace Robotico.Nexus.Client;
/// <summary>Builds gRPC channels (TLS/mTLS or plaintext h2c) for a parsed Nexus connection URI.</summary>
internal static class NexusGrpcChannelFactory
{
/// <summary>Resolves the wire address (scheme://host:port) for a parsed URI.</summary>
internal static string ResolveAddress(NexusConnectionUriParts parsed, bool useHttps) =>
useHttps ? $"https://{parsed.Host}:{parsed.Port}" : $"http://{parsed.Host}:{parsed.Port}";
/// <summary>Creates a channel, applying mTLS client material when provided.</summary>
[SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", Justification = "The SocketsHttpHandler and X509Certificate2 credentials are handed to GrpcChannel, which takes ownership and disposes them (transitively, via the handler) when the channel is disposed; they must stay alive for the channel's lifetime.")]
internal static Robotico.Result.Result<GrpcChannel> CreateChannel(NexusConnectionUriParts parsed, NexusTlsMaterial? material, bool useHttps)
{
SocketsHttpHandler handler = CreateTunedSocketsHandler();
if (material is not null)
{
try
{
material.EnsureFilesExist();
X509Certificate2 clientCertificate = X509Certificate2.CreateFromPemFile(material.ClientCertificateFile, material.ClientKeyFile);
handler.SslOptions.ClientCertificates = [clientCertificate];
X509Certificate2 caCertificate = X509Certificate2.CreateFromPem(File.ReadAllText(material.CaCertificateFile));
handler.SslOptions.CertificateChainPolicy = new X509ChainPolicy
{
TrustMode = X509ChainTrustMode.CustomRootTrust,
RevocationMode = X509RevocationMode.NoCheck,
VerificationFlags = X509VerificationFlags.IgnoreInvalidName,
};
handler.SslOptions.CertificateChainPolicy.CustomTrustStore.Add(caCertificate);
}
catch (Exception ex) when (ex is InvalidOperationException or IOException or CryptographicException)
{
return Robotico.Result.Result.Error<GrpcChannel>(NexusErrors.Error($"Failed to load TLS material: {ex.Message}", NexusErrors.TlsRejected));
}
}
else if (!useHttps)
{
AppContext.SetSwitch("System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport", true);
}
string address = ResolveAddress(parsed, useHttps);
try
{
GrpcChannel channel = GrpcChannel.ForAddress(address, new GrpcChannelOptions { HttpHandler = handler });
return Robotico.Result.Result.Success(channel);
}
catch (Exception ex) when (ex is UriFormatException or ArgumentException or InvalidOperationException)
{
return Robotico.Result.Result.Error<GrpcChannel>(NexusErrors.Error($"Failed to create channel for '{address}': {ex.Message}", NexusErrors.ConnectFailed));
}
}
/// <summary>HTTP/2 handler tuned for low-latency unary RPCs (loopback and LAN).</summary>
private static SocketsHttpHandler CreateTunedSocketsHandler() =>
new()
{
EnableMultipleHttp2Connections = true,
PooledConnectionIdleTimeout = Timeout.InfiniteTimeSpan,
KeepAlivePingDelay = TimeSpan.FromSeconds(60),
KeepAlivePingTimeout = TimeSpan.FromSeconds(30),
ConnectTimeout = TimeSpan.FromSeconds(5),
};
}

View File

@ -0,0 +1,78 @@
namespace Robotico.Nexus.Client;
/// <summary>
/// Per-connection TLS/mTLS material for a Nexus machine credential. Uses the same environment
/// variable names as <c>Mishima.Nexus.Wire.Clients.WireTlsMaterial</c> (POLA) without depending on
/// that assembly — see docs/adr/0001-nexus-client-monolith.adoc.
/// </summary>
public sealed class NexusTlsMaterial
{
/// <summary>Creates material with required PEM paths.</summary>
public NexusTlsMaterial(string caCertificateFile, string clientCertificateFile, string clientKeyFile, bool useHttps = true)
{
ArgumentException.ThrowIfNullOrWhiteSpace(caCertificateFile);
ArgumentException.ThrowIfNullOrWhiteSpace(clientCertificateFile);
ArgumentException.ThrowIfNullOrWhiteSpace(clientKeyFile);
CaCertificateFile = Path.GetFullPath(caCertificateFile.Trim());
ClientCertificateFile = Path.GetFullPath(clientCertificateFile.Trim());
ClientKeyFile = Path.GetFullPath(clientKeyFile.Trim());
UseHttps = useHttps;
}
/// <summary>CA PEM used to trust the Nexus server certificate.</summary>
public string CaCertificateFile { get; }
/// <summary>Client certificate PEM (machine credential).</summary>
public string ClientCertificateFile { get; }
/// <summary>Client private key PEM.</summary>
public string ClientKeyFile { get; }
/// <summary>When true, dial <c>https://</c> gRPC.</summary>
public bool UseHttps { get; }
/// <summary>
/// Loads material from process env: <c>NEXUS_TLS_CA_FILE</c>, <c>NEXUS_TLS_CLIENT_CERT_FILE</c>,
/// <c>NEXUS_TLS_CLIENT_KEY_FILE</c>, optional <c>NEXUS_TLS_MODE</c> (defaults to https).
/// Returns <see langword="null"/> when the required files are absent (insecure/dev connection).
/// </summary>
public static NexusTlsMaterial? TryFromEnvironment()
{
string? ca = Environment.GetEnvironmentVariable("NEXUS_TLS_CA_FILE");
string? cert = Environment.GetEnvironmentVariable("NEXUS_TLS_CLIENT_CERT_FILE");
string? key = Environment.GetEnvironmentVariable("NEXUS_TLS_CLIENT_KEY_FILE");
string mode = Environment.GetEnvironmentVariable("NEXUS_TLS_MODE") ?? string.Empty;
bool https = string.IsNullOrWhiteSpace(mode) || string.Equals(mode, "https", StringComparison.OrdinalIgnoreCase);
if (string.IsNullOrWhiteSpace(ca)
|| string.IsNullOrWhiteSpace(cert)
|| string.IsNullOrWhiteSpace(key)
|| !File.Exists(ca)
|| !File.Exists(cert)
|| !File.Exists(key))
{
return null;
}
return new NexusTlsMaterial(ca, cert, key, https);
}
/// <summary>Validates that all files exist.</summary>
public void EnsureFilesExist()
{
if (!File.Exists(CaCertificateFile))
{
throw new InvalidOperationException($"CA file not found: {CaCertificateFile}");
}
if (!File.Exists(ClientCertificateFile))
{
throw new InvalidOperationException($"Client cert not found: {ClientCertificateFile}");
}
if (!File.Exists(ClientKeyFile))
{
throw new InvalidOperationException($"Client key not found: {ClientKeyFile}");
}
}
}

View File

@ -0,0 +1,20 @@
namespace Robotico.Nexus.Client;
/// <summary>Supported Nexus wire schemes for <see cref="NexusConnectionUri"/>.</summary>
public static class NexusUriSchemes
{
/// <summary>Native graph plane wire.</summary>
public const string Graph = "nexus+graph";
/// <summary>Native time plane wire.</summary>
public const string Time = "nexus+time";
/// <summary>Native document plane wire.</summary>
public const string Document = "nexus+document";
/// <summary>Native relational plane wire.</summary>
public const string Relational = "nexus+relational";
/// <summary>SQL compatibility wire — not supported by this client (use Mishima.Nexus.Sql directly).</summary>
public const string Sql = "nexus+sql";
}

View File

@ -0,0 +1,68 @@
using Grpc.Core;
using Mishima.Nexus.Wire.V1;
using Robotico.Result.Errors;
namespace Robotico.Nexus.Client;
/// <summary>Maps <see cref="NexusStatus"/> wire envelopes and gRPC failures to <see cref="Robotico.Result.Result"/>.</summary>
internal static class NexusWireStatus
{
private const string OkCode = "OK";
/// <summary>Returns success when <paramref name="status"/> reports <c>OK</c>; otherwise a coded error.</summary>
internal static Robotico.Result.Result ToResult(NexusStatus? status)
{
if (status is null)
{
return Robotico.Result.Result.Error(NexusErrors.Error("Nexus response is missing a status envelope.", NexusErrors.WireFailed));
}
if (string.Equals(status.Code, OkCode, StringComparison.Ordinal))
{
return Robotico.Result.Result.Success();
}
string code = string.IsNullOrWhiteSpace(status.Code) ? NexusErrors.WireFailed : status.Code;
string message = string.IsNullOrWhiteSpace(status.Message) ? "Nexus call failed." : status.Message;
return Robotico.Result.Result.Error(NexusErrors.Error(message, code));
}
/// <summary>Maps status to <see cref="Robotico.Result.Result{T}"/>, carrying <paramref name="value"/> on success.</summary>
internal static Robotico.Result.Result<T> ToResult<T>(NexusStatus? status, T value)
{
Robotico.Result.Result plain = ToResult(status);
return plain.IsError(out IError? error) ? Robotico.Result.Result.Error<T>(error!) : Robotico.Result.Result.Success(value);
}
/// <summary>True when <paramref name="status"/> reports <c>OK</c>.</summary>
internal static bool IsOk(NexusStatus? status) =>
status is not null && string.Equals(status.Code, OkCode, StringComparison.Ordinal);
/// <summary>Maps a failed gRPC call into a coded <see cref="IError"/>.</summary>
internal static IError FromException(RpcException exception)
{
string code = exception.StatusCode switch
{
StatusCode.OK => NexusErrors.WireFailed,
StatusCode.Cancelled => NexusErrors.Unavailable,
StatusCode.Unknown => NexusErrors.WireFailed,
StatusCode.InvalidArgument => NexusErrors.InvalidArgument,
StatusCode.DeadlineExceeded => NexusErrors.Unavailable,
StatusCode.NotFound => NexusErrors.NotFound,
StatusCode.AlreadyExists => NexusErrors.AlreadyExists,
StatusCode.PermissionDenied => NexusErrors.WireFailed,
StatusCode.ResourceExhausted => NexusErrors.Unavailable,
StatusCode.FailedPrecondition => NexusErrors.InvalidArgument,
StatusCode.Aborted => NexusErrors.WireFailed,
StatusCode.OutOfRange => NexusErrors.InvalidArgument,
StatusCode.Unimplemented => NexusErrors.WireFailed,
StatusCode.Internal => NexusErrors.WireFailed,
StatusCode.Unavailable => NexusErrors.Unavailable,
StatusCode.DataLoss => NexusErrors.WireFailed,
StatusCode.Unauthenticated => NexusErrors.WireFailed,
_ => NexusErrors.WireFailed,
};
string detail = string.IsNullOrWhiteSpace(exception.Status.Detail) ? exception.Message : exception.Status.Detail;
return NexusErrors.Error($"gRPC call failed ({exception.StatusCode}): {detail}", code);
}
}

View File

@ -0,0 +1,41 @@
using Grpc.Core;
using Grpc.Net.Client;
using Mishima.Nexus.Wire.V1;
namespace Robotico.Nexus.Client.Relational;
/// <summary>
/// Thin relational-plane connect helper: liveness only (<see cref="CheckHealth"/>) — enough for
/// MVP smoke usage. This is <b>not</b> a full relational façade; use the generated
/// <see cref="Mishima.Nexus.Wire.V1.NexusRelationalEngineService.NexusRelationalEngineServiceClient"/>
/// directly (via <see cref="NexusClient.CreateRelationalWireClient"/>) for row-level RPCs.
/// </summary>
public sealed class NexusRelationalClient
{
private readonly NexusRelationalEngineService.NexusRelationalEngineServiceClient _client;
private readonly string _databaseName;
private readonly string? _bearerToken;
internal NexusRelationalClient(GrpcChannel channel, string databaseName, string? bearerToken)
{
_client = new NexusRelationalEngineService.NexusRelationalEngineServiceClient(channel);
_databaseName = databaseName;
_bearerToken = bearerToken;
}
/// <summary>Calls <c>NexusRelationalEngineService.Ping</c>.</summary>
public Robotico.Result.Result CheckHealth()
{
try
{
RelationalPingResponse response = _client.Ping(new RelationalPingRequest(), Metadata());
return NexusWireStatus.ToResult(response.Status);
}
catch (RpcException ex)
{
return Robotico.Result.Result.Error(NexusWireStatus.FromException(ex));
}
}
private Metadata Metadata() => NexusCallMetadata.Build(_databaseName, _bearerToken);
}

View File

@ -0,0 +1,42 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>Robotico.Nexus.Client</RootNamespace>
<AssemblyName>Robotico.Nexus.Client</AssemblyName>
<PackageProjectUrl>https://brokkr.robotico.dev/robotico/robotico-nexus-client-csharp</PackageProjectUrl>
<RepositoryUrl>https://brokkr.robotico.dev/robotico/robotico-nexus-client-csharp</RepositoryUrl>
<RepositoryBranch>main</RepositoryBranch>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
<IsPackable>true</IsPackable>
</PropertyGroup>
<ItemGroup>
<None Include="..\..\README.md" Pack="true" PackagePath="\" Visible="false" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.SourceLink.GitHub" PrivateAssets="All" />
<PackageReference Include="Robotico.Result" />
<PackageReference Include="Grpc.Net.Client" />
</ItemGroup>
<ItemGroup Condition="Exists('$(RoboticoGraphHubProjectPath)')">
<ProjectReference Include="$(RoboticoGraphHubProjectPath)" />
</ItemGroup>
<ItemGroup Condition="!Exists('$(RoboticoGraphHubProjectPath)')">
<PackageReference Include="Robotico.Graph" />
</ItemGroup>
<ItemGroup Condition="Exists('$(RoboticoTimeHubProjectPath)')">
<ProjectReference Include="$(RoboticoTimeHubProjectPath)" />
</ItemGroup>
<ItemGroup Condition="!Exists('$(RoboticoTimeHubProjectPath)')">
<PackageReference Include="Robotico.Time" />
</ItemGroup>
<ItemGroup Condition="Exists('$(MishimaNexusWireV1ProjectPath)')">
<ProjectReference Include="$(MishimaNexusWireV1ProjectPath)" />
</ItemGroup>
<ItemGroup Condition="!Exists('$(MishimaNexusWireV1ProjectPath)')">
<PackageReference Include="Mishima.Nexus.Wire.V1" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Robotico.Nexus.Client.Tests" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,364 @@
using Grpc.Core;
using Mishima.Nexus.Wire.V1;
using Robotico.Result.Errors;
using Robotico.Time;
namespace Robotico.Nexus.Client.Time;
/// <summary>
/// <see cref="ITimeSession"/> bound to one Nexus logical time database. <see cref="Doctor"/> is
/// synthesized from <c>NexusTimeEngineService.Doctor</c> — <see cref="TimeDoctorReport.PrimaryPath"/> /
/// <see cref="TimeDoctorReport.WalPath"/> are the logical <c>nexus://</c> identity (Nexus does not expose
/// server-side filesystem paths to remote clients), and <see cref="TimeDoctorReport.MaxActiveSeries"/> is
/// always <c>0</c> (the remote engine, not this client, enforces any cap).
/// </summary>
public sealed class NexusTimeSession : ITimeSession
{
private readonly NexusTimeEngineService.NexusTimeEngineServiceClient _client;
private readonly string _databaseName;
private readonly string? _bearerToken;
private bool _disposed;
internal NexusTimeSession(NexusTimeEngineService.NexusTimeEngineServiceClient client, string databaseName, string? bearerToken)
{
_client = client;
_databaseName = databaseName;
_bearerToken = bearerToken;
}
/// <inheritdoc />
public Robotico.Result.Result<TimeDoctorReport> Doctor()
{
Robotico.Result.Result disposed = EnsureNotDisposed();
if (disposed.IsError(out IError? disposedError))
{
return Robotico.Result.Result.Error<TimeDoctorReport>(disposedError!);
}
return InvokeGeneric(() =>
{
TimeDoctorResponse response = _client.Doctor(new TimeDoctorRequest(), Metadata());
string identity = $"nexus://{_databaseName}";
TimeDoctorReport report = new(
PrimaryPath: identity,
WalPath: identity,
ActiveSeriesCount: response.ActiveSeriesCount,
TotalSampleCount: response.TotalSampleCount,
ActiveEventTableCount: response.ActiveEventTableCount,
TotalEventCount: response.TotalEventCount,
PrimaryLengthBytes: response.PrimaryLengthBytes,
WalLengthBytes: response.WalLengthBytes,
MaxActiveSeries: 0,
PrimaryFormatVersion: (ushort)response.PrimaryFormatVersion,
WalFormatVersion: (ushort)response.WalFormatVersion);
return NexusWireStatus.ToResult(response.Status, report);
});
}
/// <inheritdoc />
public Robotico.Result.Result<ulong> RegisterSeries(TimeSeriesKey key)
{
Robotico.Result.Result disposed = EnsureNotDisposed();
if (disposed.IsError(out IError? disposedError))
{
return Robotico.Result.Result.Error<ulong>(disposedError!);
}
if (key is null || string.IsNullOrWhiteSpace(key.MetricName))
{
return Robotico.Result.Result.Error<ulong>(TimeErrors.Error("Metric name is required.", TimeErrors.InvalidArgument));
}
return InvokeGeneric(() =>
{
TimeRegisterSeriesRequest request = new() { MetricName = key.MetricName.Trim() };
foreach (KeyValuePair<string, string> label in key.Labels)
{
request.Labels.Add(new TimeLabelPair { Key = label.Key, Value = label.Value });
}
TimeRegisterSeriesResponse response = _client.RegisterSeries(request, Metadata());
return NexusWireStatus.ToResult(response.Status, response.SeriesId);
});
}
/// <inheritdoc />
public Robotico.Result.Result AppendSample(ulong seriesId, long timestampUnixNanos, double value)
{
Robotico.Result.Result disposed = EnsureNotDisposed();
if (disposed.IsError(out IError? disposedError))
{
return Robotico.Result.Result.Error(disposedError!);
}
if (!double.IsFinite(value))
{
return Robotico.Result.Result.Error(TimeErrors.Error("Sample value must be finite.", TimeErrors.InvalidArgument));
}
return Invoke(() =>
{
TimeAppendSampleRequest request = new()
{
SeriesId = seriesId,
TimestampUnixNanos = timestampUnixNanos,
Value = value,
};
TimeAppendSampleResponse response = _client.AppendSample(request, Metadata());
return NexusWireStatus.ToResult(response.Status);
});
}
/// <inheritdoc />
public Robotico.Result.Result AppendSampleBatch(ulong seriesId, IReadOnlyList<TimeSample> samples)
{
Robotico.Result.Result disposed = EnsureNotDisposed();
if (disposed.IsError(out IError? disposedError))
{
return Robotico.Result.Result.Error(disposedError!);
}
if (samples is null || samples.Count is 0)
{
return Robotico.Result.Result.Error(TimeErrors.Error("At least one sample is required.", TimeErrors.InvalidArgument));
}
foreach (TimeSample sample in samples)
{
if (!double.IsFinite(sample.Value))
{
return Robotico.Result.Result.Error(TimeErrors.Error("Sample value must be finite.", TimeErrors.InvalidArgument));
}
}
return Invoke(() =>
{
TimeAppendSampleBatchRequest request = new() { SeriesId = seriesId };
foreach (TimeSample sample in samples)
{
request.Samples.Add(new TimeSampleWire { TimestampUnixNanos = sample.TimestampUnixNanos, Value = sample.Value });
}
TimeAppendSampleResponse response = _client.AppendSampleBatch(request, Metadata());
return NexusWireStatus.ToResult(response.Status);
});
}
/// <inheritdoc />
public Robotico.Result.Result<IReadOnlyList<TimeSample>> QueryRange(ulong seriesId, long fromInclusive, long toExclusive, int limit)
{
Robotico.Result.Result disposed = EnsureNotDisposed();
if (disposed.IsError(out IError? disposedError))
{
return Robotico.Result.Result.Error<IReadOnlyList<TimeSample>>(disposedError!);
}
int normalizedLimit = TimePaging.Normalize(limit);
return InvokeGeneric(() =>
{
TimeQueryRangeRequest request = new()
{
SeriesId = seriesId,
FromInclusive = fromInclusive,
ToExclusive = toExclusive,
Limit = normalizedLimit,
};
TimeQueryRangeResponse response = _client.QueryRange(request, Metadata());
IReadOnlyList<TimeSample> samples =
[.. response.Samples.Select(static s => new TimeSample(s.TimestampUnixNanos, s.Value))];
return NexusWireStatus.ToResult(response.Status, samples);
});
}
/// <inheritdoc />
public Robotico.Result.Result<TimeSample?> Last(ulong seriesId)
{
Robotico.Result.Result disposed = EnsureNotDisposed();
if (disposed.IsError(out IError? disposedError))
{
return Robotico.Result.Result.Error<TimeSample?>(disposedError!);
}
return InvokeGeneric(() =>
{
TimeLastResponse response = _client.Last(new TimeLastRequest { SeriesId = seriesId }, Metadata());
TimeSample? sample = response.HasSample
? new TimeSample(response.Sample.TimestampUnixNanos, response.Sample.Value)
: null;
return NexusWireStatus.ToResult(response.Status, sample);
});
}
/// <inheritdoc />
public Robotico.Result.Result<ulong> RegisterEventTable(TimeEventTableSchema schema)
{
Robotico.Result.Result disposed = EnsureNotDisposed();
if (disposed.IsError(out IError? disposedError))
{
return Robotico.Result.Result.Error<ulong>(disposedError!);
}
if (schema is null || string.IsNullOrWhiteSpace(schema.TableName) || schema.Fields.Count is 0)
{
return Robotico.Result.Result.Error<ulong>(TimeErrors.Error("Table name and at least one field are required.", TimeErrors.InvalidArgument));
}
return InvokeGeneric(() =>
{
TimeRegisterEventTableRequest request = new() { TableName = schema.TableName.Trim() };
foreach (TimeEventField field in schema.Fields)
{
request.Fields.Add(new TimeEventFieldWire
{
Name = field.Name,
WireTypeName = field.WireTypeName,
IndexKind = TimeWireMapping.ToWire(field.IndexKind),
});
}
TimeRegisterEventTableResponse response = _client.RegisterEventTable(request, Metadata());
return NexusWireStatus.ToResult(response.Status, response.TableId);
});
}
/// <inheritdoc />
public Robotico.Result.Result<ulong> AppendEvent(ulong tableId, long timestampUnixNanos, IReadOnlyList<TimeCell> cells)
{
Robotico.Result.Result disposed = EnsureNotDisposed();
if (disposed.IsError(out IError? disposedError))
{
return Robotico.Result.Result.Error<ulong>(disposedError!);
}
if (cells is null || cells.Count is 0)
{
return Robotico.Result.Result.Error<ulong>(TimeErrors.Error("At least one cell is required.", TimeErrors.InvalidArgument));
}
return InvokeGeneric(() =>
{
TimeAppendEventRequest request = new() { TableId = tableId, TimestampUnixNanos = timestampUnixNanos };
foreach (TimeCell cell in cells)
{
request.Cells.Add(TimeWireMapping.ToWire(cell));
}
TimeAppendEventResponse response = _client.AppendEvent(request, Metadata());
return NexusWireStatus.ToResult(response.Status, response.EventId);
});
}
/// <inheritdoc />
public Robotico.Result.Result<IReadOnlyList<TimeWideEvent>> QueryEvents(ulong tableId, long fromInclusive, long toExclusive, int limit)
{
Robotico.Result.Result disposed = EnsureNotDisposed();
if (disposed.IsError(out IError? disposedError))
{
return Robotico.Result.Result.Error<IReadOnlyList<TimeWideEvent>>(disposedError!);
}
int normalizedLimit = TimePaging.Normalize(limit);
return InvokeGeneric(() =>
{
TimeQueryEventsRequest request = new()
{
TableId = tableId,
FromInclusive = fromInclusive,
ToExclusive = toExclusive,
Limit = normalizedLimit,
};
TimeQueryEventsResponse response = _client.QueryEvents(request, Metadata());
return NexusWireStatus.ToResult(response.Status, ToWideEvents(response.Events));
});
}
/// <inheritdoc />
public Robotico.Result.Result<IReadOnlyList<TimeWideEvent>> LookupInverted(ulong tableId, ushort fieldOrdinal, string exactValue, int limit)
{
Robotico.Result.Result disposed = EnsureNotDisposed();
if (disposed.IsError(out IError? disposedError))
{
return Robotico.Result.Result.Error<IReadOnlyList<TimeWideEvent>>(disposedError!);
}
int normalizedLimit = TimePaging.Normalize(limit);
return InvokeGeneric(() =>
{
TimeLookupInvertedRequest request = new()
{
TableId = tableId,
FieldOrdinal = fieldOrdinal,
ExactValue = exactValue ?? string.Empty,
Limit = normalizedLimit,
};
TimeQueryEventsResponse response = _client.LookupInverted(request, Metadata());
return NexusWireStatus.ToResult(response.Status, ToWideEvents(response.Events));
});
}
/// <inheritdoc />
public Robotico.Result.Result<IReadOnlyList<TimeSearchHit>> SearchFullText(ulong tableId, ushort fieldOrdinal, string query, int limit)
{
Robotico.Result.Result disposed = EnsureNotDisposed();
if (disposed.IsError(out IError? disposedError))
{
return Robotico.Result.Result.Error<IReadOnlyList<TimeSearchHit>>(disposedError!);
}
int normalizedLimit = TimePaging.Normalize(limit);
return InvokeGeneric(() =>
{
TimeSearchFullTextRequest request = new()
{
TableId = tableId,
FieldOrdinal = fieldOrdinal,
Query = query ?? string.Empty,
Limit = normalizedLimit,
};
TimeSearchFullTextResponse response = _client.SearchFullText(request, Metadata());
IReadOnlyList<TimeSearchHit> hits =
[.. response.Hits.Select(static h => new TimeSearchHit(h.EventId, h.TimestampUnixNanos, h.Score))];
return NexusWireStatus.ToResult(response.Status, hits);
});
}
/// <inheritdoc />
public void Dispose() => _disposed = true;
private static IReadOnlyList<TimeWideEvent> ToWideEvents(IEnumerable<TimeWideEventWire> events) =>
[.. events.Select(static e => new TimeWideEvent(
e.EventId,
e.TimestampUnixNanos,
[.. e.Cells.Select(TimeWireMapping.FromWire)]))];
private Metadata Metadata() => NexusCallMetadata.Build(_databaseName, _bearerToken);
private Robotico.Result.Result EnsureNotDisposed() =>
_disposed
? Robotico.Result.Result.Error(NexusErrors.Error("Time session is disposed.", NexusErrors.Disposed))
: Robotico.Result.Result.Success();
private static Robotico.Result.Result Invoke(Func<Robotico.Result.Result> call)
{
try
{
return call();
}
catch (RpcException ex)
{
return Robotico.Result.Result.Error(NexusWireStatus.FromException(ex));
}
}
private static Robotico.Result.Result<T> InvokeGeneric<T>(Func<Robotico.Result.Result<T>> call)
{
try
{
return call();
}
catch (RpcException ex)
{
return Robotico.Result.Result.Error<T>(NexusWireStatus.FromException(ex));
}
}
}

View File

@ -0,0 +1,111 @@
using System.Diagnostics.CodeAnalysis;
using Grpc.Core;
using Grpc.Net.Client;
using Mishima.Nexus.Wire.V1;
using Robotico.Result.Errors;
using Robotico.Time;
namespace Robotico.Nexus.Client.Time;
/// <summary>
/// <see cref="ITimeStore"/> over the Nexus time plane gRPC service. Per POLA, the
/// <c>databaseFilePath</c> argument on every method is the <b>logical database name</b> (Nexus
/// <c>x-nexus-database</c> metadata value), not a filesystem path — see
/// docs/adr/0001-nexus-client-monolith.adoc.
/// </summary>
/// <remarks>
/// <c>Ping</c> is an engine-liveness probe on the Nexus time host, not a per-database
/// existence check, so <see cref="Create"/> will typically report <see cref="NexusErrors.AlreadyExists"/>
/// once the engine is reachable. Use <see cref="OpenOrCreate"/> for greenfield databases — the Nexus
/// host provisions the logical database lazily on the first RPC that touches its state.
/// </remarks>
public sealed class NexusTimeStore : ITimeStore
{
private readonly NexusTimeEngineService.NexusTimeEngineServiceClient _client;
private readonly string? _bearerToken;
private readonly string _defaultDatabaseName;
internal NexusTimeStore(GrpcChannel channel, string? bearerToken, string defaultDatabaseName)
{
_client = new NexusTimeEngineService.NexusTimeEngineServiceClient(channel);
_bearerToken = bearerToken;
_defaultDatabaseName = defaultDatabaseName;
}
/// <inheritdoc />
public Robotico.Result.Result<ITimeSession> Create(string databaseFilePath, int maxActiveSeries = 0)
{
Robotico.Result.Result<string> nameResult = ResolveName(databaseFilePath);
if (!nameResult.IsSuccess(out string? name, out IError? nameError))
{
return Robotico.Result.Result.Error<ITimeSession>(nameError!);
}
Robotico.Result.Result<bool> reachable = PingReachable(name!);
if (!reachable.IsSuccess(out bool alreadyUp, out IError? pingError))
{
return Robotico.Result.Result.Error<ITimeSession>(pingError!);
}
if (alreadyUp)
{
return Robotico.Result.Result.Error<ITimeSession>(NexusErrors.Error(
$"Time database '{name}' already answers to Ping. Nexus Ping is engine-level, not " +
"per-database, so Create cannot distinguish a fresh logical database — use OpenOrCreate.",
NexusErrors.AlreadyExists));
}
return OpenCore(name!);
}
/// <inheritdoc />
public Robotico.Result.Result<ITimeSession> Open(string databaseFilePath)
{
Robotico.Result.Result<string> nameResult = ResolveName(databaseFilePath);
return nameResult.IsSuccess(out string? name, out IError? nameError)
? OpenCore(name!)
: Robotico.Result.Result.Error<ITimeSession>(nameError!);
}
/// <inheritdoc />
public Robotico.Result.Result<ITimeSession> OpenOrCreate(string databaseFilePath, int maxActiveSeries = 0) => Open(databaseFilePath);
[SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", Justification = "Ownership of the returned NexusTimeSession transfers to the caller via Result<ITimeSession>.Success; the caller is responsible for disposing it.")]
private Robotico.Result.Result<ITimeSession> OpenCore(string name)
{
Robotico.Result.Result<bool> reachable = PingReachable(name);
if (!reachable.IsSuccess(out bool ok, out IError? pingError))
{
return Robotico.Result.Result.Error<ITimeSession>(pingError!);
}
return ok
? Robotico.Result.Result.Success<ITimeSession>(new NexusTimeSession(_client, name, _bearerToken))
: Robotico.Result.Result.Error<ITimeSession>(NexusErrors.Error($"Time engine ping failed for database '{name}'.", NexusErrors.Unavailable));
}
private Robotico.Result.Result<bool> PingReachable(string name)
{
try
{
TimePingResponse response = _client.Ping(new TimePingRequest(), NexusCallMetadata.Build(name, _bearerToken));
return Robotico.Result.Result.Success(NexusWireStatus.IsOk(response.Status));
}
catch (RpcException ex)
{
return Robotico.Result.Result.Error<bool>(NexusWireStatus.FromException(ex));
}
}
private Robotico.Result.Result<string> ResolveName(string databaseFilePath)
{
if (string.IsNullOrWhiteSpace(databaseFilePath))
{
return string.IsNullOrWhiteSpace(_defaultDatabaseName)
? Robotico.Result.Result.Error<string>(NexusErrors.Error("Logical database name is required.", NexusErrors.InvalidArgument))
: Robotico.Result.Result.Success(_defaultDatabaseName);
}
return Robotico.Result.Result.Success(databaseFilePath.Trim());
}
}

View File

@ -0,0 +1,66 @@
using Mishima.Nexus.Wire.V1;
using Robotico.Time;
namespace Robotico.Nexus.Client.Time;
/// <summary>Bidirectional mapping between <c>Robotico.Time</c> DTOs and Wire.V1 time messages.</summary>
internal static class TimeWireMapping
{
/// <summary>Maps a port-level cell to its wire representation, keyed by <see cref="TimeCell.WireTypeName"/>.</summary>
internal static TimeCellWire ToWire(TimeCell cell)
{
TimeCellWire wire = new() { WireTypeName = cell.WireTypeName, IsNull = cell.IsNull };
if (cell.IsNull)
{
return wire;
}
switch (cell.WireTypeName)
{
case "Int64":
wire.I64 = cell.I64 ?? 0;
break;
case "UInt64":
wire.U64 = cell.U64 ?? 0;
break;
case "Double":
wire.F64 = cell.F64 ?? 0;
break;
case "Boolean":
wire.Boolean = cell.Boolean ?? false;
break;
default:
wire.Utf8 = cell.Utf8 ?? string.Empty;
break;
}
return wire;
}
/// <summary>Maps a wire cell back to the port-level DTO, keyed by <see cref="TimeCellWire.WireTypeName"/>.</summary>
internal static TimeCell FromWire(TimeCellWire wire)
{
if (wire.IsNull)
{
return new TimeCell(wire.WireTypeName, IsNull: true);
}
return wire.WireTypeName switch
{
"Int64" => new TimeCell(wire.WireTypeName, I64: wire.I64),
"UInt64" => new TimeCell(wire.WireTypeName, U64: wire.U64),
"Double" => new TimeCell(wire.WireTypeName, F64: wire.F64),
"Boolean" => new TimeCell(wire.WireTypeName, Boolean: wire.Boolean),
_ => new TimeCell(wire.WireTypeName, Utf8: wire.Utf8),
};
}
/// <summary>Maps a port-level field index kind to the wire enum.</summary>
internal static Mishima.Nexus.Wire.V1.TimeEventFieldIndexKind ToWire(Robotico.Time.TimeEventFieldIndexKind kind) => kind switch
{
Robotico.Time.TimeEventFieldIndexKind.None => Mishima.Nexus.Wire.V1.TimeEventFieldIndexKind.None,
Robotico.Time.TimeEventFieldIndexKind.Inverted => Mishima.Nexus.Wire.V1.TimeEventFieldIndexKind.Inverted,
Robotico.Time.TimeEventFieldIndexKind.FullText => Mishima.Nexus.Wire.V1.TimeEventFieldIndexKind.FullText,
_ => Mishima.Nexus.Wire.V1.TimeEventFieldIndexKind.None,
};
}

View File

@ -0,0 +1,96 @@
using Robotico.Nexus.Client.Document;
using Robotico.Nexus.Client.Tests.TestSupport;
using Robotico.Result.Errors;
using Xunit;
namespace Robotico.Nexus.Client.Tests.Document;
public class NexusDocumentClientTests : IAsyncLifetime
{
private NexusTestHost _host = null!;
private NexusClient _client = null!;
private NexusDocumentClient _documentClient = null!;
public async Task InitializeAsync()
{
_host = await NexusTestHost.StartAsync();
Robotico.Result.Result<NexusClient> connected = NexusClient.Connect(_host.ConnectionUri(NexusUriSchemes.Document));
Assert.True(connected.IsSuccess(out NexusClient? client, out _));
_client = client!;
Robotico.Result.Result<NexusDocumentClient> created = _client.CreateDocumentClient();
Assert.True(created.IsSuccess(out NexusDocumentClient? documentClient, out _));
_documentClient = documentClient!;
}
public async Task DisposeAsync()
{
_client.Dispose();
await _host.DisposeAsync();
}
[Fact]
public void CheckHealth_Succeeds()
{
Robotico.Result.Result result = _documentClient.CheckHealth();
Assert.True(result.IsSuccess());
}
[Fact]
public void UpsertDocument_ThenGetDocument_RoundTrips()
{
Robotico.Result.Result upserted = _documentClient.UpsertDocument("users", "u1", """{"name":"Ada"}""");
Assert.True(upserted.IsSuccess());
Robotico.Result.Result<string?> fetched = _documentClient.GetDocument("users", "u1");
Assert.True(fetched.IsSuccess(out string? body, out _));
Assert.Equal("""{"name":"Ada"}""", body);
}
[Fact]
public void GetDocument_ReturnsNull_WhenNotFound()
{
Robotico.Result.Result<string?> result = _documentClient.GetDocument("users", "missing");
Assert.True(result.IsSuccess(out string? body, out _));
Assert.Null(body);
}
[Fact]
public void DeleteDocument_RemovesDocument()
{
_documentClient.UpsertDocument("users", "u2", "{}");
Robotico.Result.Result deleted = _documentClient.DeleteDocument("users", "u2");
Assert.True(deleted.IsSuccess());
Robotico.Result.Result<string?> fetched = _documentClient.GetDocument("users", "u2");
Assert.True(fetched.IsSuccess(out string? body, out _));
Assert.Null(body);
}
[Fact]
public void DeleteDocument_Succeeds_WhenAlreadyAbsent()
{
Robotico.Result.Result result = _documentClient.DeleteDocument("users", "never-existed");
Assert.True(result.IsSuccess());
}
[Fact]
public void CheckHealth_Fails_WhenEngineReportsFailure()
{
Robotico.Result.Result<NexusClient> connected = NexusClient.Connect(
_host.ConnectionUri(NexusUriSchemes.Document, FakeNexusTriggers.PingReportsFailure));
Assert.True(connected.IsSuccess(out NexusClient? client, out _));
using NexusClient failingClient = client!;
Robotico.Result.Result<NexusDocumentClient> created = failingClient.CreateDocumentClient();
Assert.True(created.IsSuccess(out NexusDocumentClient? failingDocumentClient, out _));
Robotico.Result.Result result = failingDocumentClient!.CheckHealth();
Assert.True(result.IsError(out IError? error));
Assert.Equal(NexusErrors.Unavailable, error!.Code);
}
}

View File

@ -0,0 +1,250 @@
using Robotico.Graph;
using Robotico.Nexus.Client.Tests.TestSupport;
using Robotico.Result.Errors;
using Xunit;
namespace Robotico.Nexus.Client.Tests.Graph;
public class NexusGraphSessionTests : IAsyncLifetime
{
private NexusTestHost _host = null!;
private NexusClient _client = null!;
private IGraphSession _session = null!;
public async Task InitializeAsync()
{
_host = await NexusTestHost.StartAsync();
Robotico.Result.Result<NexusClient> connected = NexusClient.Connect(_host.ConnectionUri(NexusUriSchemes.Graph));
Assert.True(connected.IsSuccess(out NexusClient? client, out _));
_client = client!;
Robotico.Result.Result<IGraphSession> opened = _client.Graph.Open("session-db");
Assert.True(opened.IsSuccess(out IGraphSession? session, out _));
_session = session!;
}
public async Task DisposeAsync()
{
_session.Dispose();
_client.Dispose();
await _host.DisposeAsync();
}
[Fact]
public void RegisterLabel_Succeeds()
{
Robotico.Result.Result result = _session.RegisterLabel("Person");
Assert.True(result.IsSuccess());
}
[Fact]
public void RegisterLabel_Fails_WhenBlank()
{
Robotico.Result.Result result = _session.RegisterLabel(" ");
Assert.True(result.IsError(out IError? error));
Assert.Equal(GraphErrors.InvalidArgument, error!.Code);
}
[Fact]
public void RegisterEdgeType_Succeeds()
{
Robotico.Result.Result result = _session.RegisterEdgeType("WorksAt");
Assert.True(result.IsSuccess());
}
[Fact]
public void RegisterEdgeType_Fails_WhenBlank()
{
Robotico.Result.Result result = _session.RegisterEdgeType(string.Empty);
Assert.True(result.IsError(out IError? error));
Assert.Equal(GraphErrors.InvalidArgument, error!.Code);
}
[Fact]
public void UpsertVertex_ThenGetVertex_RoundTrips()
{
Robotico.Result.Result<ulong> upserted = _session.UpsertVertex(1, ["Person"]);
Assert.True(upserted.IsSuccess());
Robotico.Result.Result<GraphVertex> fetched = _session.GetVertex(1);
Assert.True(fetched.IsSuccess(out GraphVertex? vertex, out _));
Assert.Equal(1ul, vertex!.VertexId);
Assert.Contains("Person", vertex.Labels);
}
[Fact]
public void UpsertVertex_Fails_WhenNoLabels()
{
Robotico.Result.Result<ulong> result = _session.UpsertVertex(1, []);
Assert.True(result.IsError(out IError? error));
Assert.Equal(GraphErrors.InvalidArgument, error!.Code);
}
[Fact]
public void GetVertex_Fails_WhenNotFound()
{
Robotico.Result.Result<GraphVertex> result = _session.GetVertex(999);
Assert.True(result.IsError(out IError? error));
Assert.Equal(NexusErrors.NotFound, error!.Code);
}
[Fact]
public void DeleteVertex_Succeeds()
{
_session.UpsertVertex(2, ["Person"]);
Robotico.Result.Result<ulong> result = _session.DeleteVertex(2);
Assert.True(result.IsSuccess());
Assert.True(_session.GetVertex(2).IsError(out _));
}
[Fact]
public void UpsertEdge_ThenGetEdge_RoundTrips()
{
_session.UpsertVertex(1, ["Person"]);
_session.UpsertVertex(2, ["Company"]);
Robotico.Result.Result<ulong> upserted = _session.UpsertEdge(1, 2, "WorksAt");
Assert.True(upserted.IsSuccess());
Robotico.Result.Result<GraphEdge> fetched = _session.GetEdge(1, 2, "WorksAt");
Assert.True(fetched.IsSuccess(out GraphEdge? edge, out _));
Assert.Equal(1ul, edge!.SourceVertexId);
Assert.Equal(2ul, edge.TargetVertexId);
Assert.Equal("WorksAt", edge.EdgeTypeName);
}
[Fact]
public void UpsertEdge_Fails_WhenEdgeTypeBlank()
{
Robotico.Result.Result<ulong> result = _session.UpsertEdge(1, 2, string.Empty);
Assert.True(result.IsError(out IError? error));
Assert.Equal(GraphErrors.InvalidArgument, error!.Code);
}
[Fact]
public void DeleteEdge_Succeeds()
{
_session.UpsertVertex(1, ["Person"]);
_session.UpsertVertex(2, ["Company"]);
_session.UpsertEdge(1, 2, "WorksAt");
Robotico.Result.Result<ulong> result = _session.DeleteEdge(1, 2, "WorksAt");
Assert.True(result.IsSuccess());
Assert.True(_session.GetEdge(1, 2, "WorksAt").IsError(out _));
}
[Fact]
public void DeleteEdge_Fails_WhenEdgeTypeBlank()
{
Robotico.Result.Result<ulong> result = _session.DeleteEdge(1, 2, " ");
Assert.True(result.IsError(out IError? error));
Assert.Equal(GraphErrors.InvalidArgument, error!.Code);
}
[Fact]
public void GetEdge_Fails_WhenEdgeTypeBlank()
{
Robotico.Result.Result<GraphEdge> result = _session.GetEdge(1, 2, string.Empty);
Assert.True(result.IsError(out IError? error));
Assert.Equal(GraphErrors.InvalidArgument, error!.Code);
}
[Fact]
public void GetEdge_Fails_WhenNotFound()
{
Robotico.Result.Result<GraphEdge> result = _session.GetEdge(1, 2, "Missing");
Assert.True(result.IsError(out IError? error));
Assert.Equal(NexusErrors.NotFound, error!.Code);
}
[Fact]
public void GetNeighborhood_ReturnsStartVertex()
{
Robotico.Result.Result<GraphNeighborhood> result = _session.GetNeighborhood(1, maxDepth: 2, edgeTypeName: "WorksAt");
Assert.True(result.IsSuccess(out GraphNeighborhood? neighborhood, out _));
Assert.Equal(1ul, neighborhood!.StartVertexId);
Assert.Contains(1ul, neighborhood.VisitOrder);
}
[Fact]
public void GetNeighborhood_Succeeds_WithoutOptionalArguments()
{
Robotico.Result.Result<GraphNeighborhood> result = _session.GetNeighborhood(1);
Assert.True(result.IsSuccess());
}
[Fact]
public void ListVerticesByLabel_ReturnsMatchingVertices()
{
_session.UpsertVertex(10, ["Person"]);
_session.UpsertVertex(11, ["Person"]);
_session.UpsertVertex(12, ["Company"]);
Robotico.Result.Result<GraphVertexPage> result = _session.ListVerticesByLabel("Person", skip: 0, limit: 25);
Assert.True(result.IsSuccess(out GraphVertexPage? page, out _));
Assert.Equal("Person", page!.LabelName);
Assert.Equal(2, page.TotalCount);
}
[Fact]
public void ListVerticesByLabel_Fails_WhenLabelBlank()
{
Robotico.Result.Result<GraphVertexPage> result = _session.ListVerticesByLabel(string.Empty);
Assert.True(result.IsError(out IError? error));
Assert.Equal(GraphErrors.InvalidArgument, error!.Code);
}
[Fact]
public void AllOperations_Fail_AfterDispose()
{
_session.Dispose();
Assert.True(_session.RegisterLabel("x").IsError(out IError? e1));
Assert.Equal(NexusErrors.Disposed, e1!.Code);
Assert.True(_session.RegisterEdgeType("x").IsError(out IError? e2));
Assert.Equal(NexusErrors.Disposed, e2!.Code);
Assert.True(_session.UpsertVertex(1, ["A"]).IsError(out IError? e3));
Assert.Equal(NexusErrors.Disposed, e3!.Code);
Assert.True(_session.DeleteVertex(1).IsError(out IError? e4));
Assert.Equal(NexusErrors.Disposed, e4!.Code);
Assert.True(_session.GetVertex(1).IsError(out IError? e5));
Assert.Equal(NexusErrors.Disposed, e5!.Code);
Assert.True(_session.UpsertEdge(1, 2, "x").IsError(out IError? e6));
Assert.Equal(NexusErrors.Disposed, e6!.Code);
Assert.True(_session.DeleteEdge(1, 2, "x").IsError(out IError? e7));
Assert.Equal(NexusErrors.Disposed, e7!.Code);
Assert.True(_session.GetEdge(1, 2, "x").IsError(out IError? e8));
Assert.Equal(NexusErrors.Disposed, e8!.Code);
Assert.True(_session.GetNeighborhood(1).IsError(out IError? e9));
Assert.Equal(NexusErrors.Disposed, e9!.Code);
Assert.True(_session.ListVerticesByLabel("x").IsError(out IError? e10));
Assert.Equal(NexusErrors.Disposed, e10!.Code);
}
}

View File

@ -0,0 +1,102 @@
using Robotico.Graph;
using Robotico.Nexus.Client.Tests.TestSupport;
using Robotico.Result.Errors;
using Xunit;
namespace Robotico.Nexus.Client.Tests.Graph;
public class NexusGraphStoreTests : IAsyncLifetime
{
private NexusTestHost _host = null!;
private NexusClient _client = null!;
public async Task InitializeAsync()
{
_host = await NexusTestHost.StartAsync();
Robotico.Result.Result<NexusClient> connected = NexusClient.Connect(_host.ConnectionUri(NexusUriSchemes.Graph));
Assert.True(connected.IsSuccess(out NexusClient? client, out _));
_client = client!;
}
public async Task DisposeAsync()
{
_client.Dispose();
await _host.DisposeAsync();
}
[Fact]
public void Open_Succeeds_WhenEngineReachable()
{
Robotico.Result.Result<IGraphSession> result = _client.Graph.Open("mydb");
Assert.True(result.IsSuccess(out IGraphSession? session, out _));
session!.Dispose();
}
[Fact]
public void Open_UsesDefaultDatabaseName_WhenPathBlank()
{
Robotico.Result.Result<IGraphSession> result = _client.Graph.Open(string.Empty);
Assert.True(result.IsSuccess(out IGraphSession? session, out _));
session!.Dispose();
}
[Fact]
public void Open_Fails_WhenPingReportsEngineFailure()
{
Robotico.Result.Result<IGraphSession> result = _client.Graph.Open(FakeNexusTriggers.PingReportsFailure);
Assert.True(result.IsError(out IError? error));
Assert.Equal(NexusErrors.Unavailable, error!.Code);
}
[Fact]
public void Open_Fails_WhenPingThrowsRpcException()
{
Robotico.Result.Result<IGraphSession> result = _client.Graph.Open(FakeNexusTriggers.PingThrows);
Assert.True(result.IsError(out IError? error));
Assert.Equal(NexusErrors.Unavailable, error!.Code);
}
[Fact]
public void OpenOrCreate_DelegatesTo_Open()
{
Robotico.Result.Result<IGraphSession> result = _client.Graph.OpenOrCreate("greenfield-db");
Assert.True(result.IsSuccess(out IGraphSession? session, out _));
session!.Dispose();
}
[Fact]
public void Create_Fails_WithAlreadyExists_WhenPingSucceeds()
{
Robotico.Result.Result<IGraphSession> result = _client.Graph.Create("already-reachable-db");
Assert.True(result.IsError(out IError? error));
Assert.Equal(NexusErrors.AlreadyExists, error!.Code);
}
[Fact]
public void Create_Fails_WhenInitialPingReportsFailure()
{
Robotico.Result.Result<IGraphSession> result = _client.Graph.Create(FakeNexusTriggers.PingReportsFailure);
Assert.True(result.IsError(out IError? error));
Assert.Equal(NexusErrors.Unavailable, error!.Code);
}
[Fact]
public void Create_RegistersInitialLabelsAndEdgeTypes()
{
Robotico.Result.Result<IGraphSession> result = _client.Graph.Create(
"brand-new-db-" + Guid.NewGuid(),
initialLabels: ["Person", "Company"],
initialEdgeTypes: ["WorksAt"]);
// Fake Ping always succeeds for a fresh sentinel-free name, so Create reports AlreadyExists.
Assert.True(result.IsError(out IError? error));
Assert.Equal(NexusErrors.AlreadyExists, error!.Code);
}
}

View File

@ -0,0 +1,40 @@
using Grpc.Core;
using Xunit;
namespace Robotico.Nexus.Client.Tests;
public class NexusCallMetadataTests
{
[Fact]
public void Build_Sets_DatabaseHeader_Only_WhenNoBearerToken()
{
Metadata metadata = NexusCallMetadata.Build("pathways", null);
Assert.Equal("pathways", metadata.GetValue(NexusCallMetadata.DatabaseKey));
Assert.Null(metadata.GetValue("Authorization"));
}
[Fact]
public void Build_Sets_DatabaseHeader_And_BlankBearerToken_IsIgnored()
{
Metadata metadata = NexusCallMetadata.Build("pathways", " ");
Assert.Null(metadata.GetValue("Authorization"));
}
[Fact]
public void Build_Adds_BearerPrefix_WhenMissing()
{
Metadata metadata = NexusCallMetadata.Build("pathways", "abc123");
Assert.Equal("Bearer abc123", metadata.GetValue("Authorization"));
}
[Fact]
public void Build_DoesNotDuplicate_BearerPrefix_WhenAlreadyPresent()
{
Metadata metadata = NexusCallMetadata.Build("pathways", "Bearer abc123");
Assert.Equal("Bearer abc123", metadata.GetValue("Authorization"));
}
}

View File

@ -0,0 +1,103 @@
using Xunit;
namespace Robotico.Nexus.Client.Tests;
[Collection("NexusTlsEnvironment")]
public class NexusClientOptionsTests : IDisposable
{
public void Dispose()
{
Environment.SetEnvironmentVariable("NEXUS_TLS_CA_FILE", null);
Environment.SetEnvironmentVariable("NEXUS_TLS_CLIENT_CERT_FILE", null);
Environment.SetEnvironmentVariable("NEXUS_TLS_CLIENT_KEY_FILE", null);
Environment.SetEnvironmentVariable("NEXUS_TLS_MODE", null);
Environment.SetEnvironmentVariable("NEXUS_WIRE_BEARER", null);
Environment.SetEnvironmentVariable("NEXUS_WIRE_DEFAULT_BEARER", null);
}
[Fact]
public void ResolveTls_PrefersExplicitOption_OverEnvironment()
{
string dir = Directory.CreateTempSubdirectory("nexus-opts-test-").FullName;
try
{
string ca = Path.Combine(dir, "ca.pem");
File.WriteAllText(ca, "ca");
NexusTlsMaterial explicitMaterial = new(ca, ca, ca);
NexusClientOptions options = new() { Tls = explicitMaterial };
Assert.Same(explicitMaterial, options.ResolveTls());
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void ResolveTls_FallsBackTo_Environment_WhenUnset()
{
NexusClientOptions options = new();
Assert.Null(options.ResolveTls());
}
[Fact]
public void ResolveBearerToken_PrefersExplicitOption()
{
Environment.SetEnvironmentVariable("NEXUS_WIRE_BEARER", "from-env");
NexusClientOptions options = new() { BearerToken = "explicit" };
Assert.Equal("explicit", options.ResolveBearerToken());
}
[Fact]
public void ResolveBearerToken_FallsBackTo_PrimaryEnvVar_ThenDefaultEnvVar()
{
NexusClientOptions options = new();
Assert.Null(options.ResolveBearerToken());
Environment.SetEnvironmentVariable("NEXUS_WIRE_DEFAULT_BEARER", "default-env");
Assert.Equal("default-env", options.ResolveBearerToken());
Environment.SetEnvironmentVariable("NEXUS_WIRE_BEARER", "primary-env");
Assert.Equal("primary-env", options.ResolveBearerToken());
}
[Fact]
public void ResolveUseHttps_UsesTlsMaterialValue_WhenTlsPresent()
{
string dir = Directory.CreateTempSubdirectory("nexus-opts-test-").FullName;
try
{
string ca = Path.Combine(dir, "ca.pem");
File.WriteAllText(ca, "ca");
NexusTlsMaterial material = new(ca, ca, ca, useHttps: false);
NexusClientOptions options = new();
Assert.False(options.ResolveUseHttps(material));
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void ResolveUseHttps_UsesExplicitOption_WhenNoTlsMaterial()
{
NexusClientOptions options = new() { UseHttps = true };
Assert.True(options.ResolveUseHttps(null));
}
[Fact]
public void ResolveUseHttps_FallsBackTo_EnvironmentMode_WhenNothingElseSet()
{
NexusClientOptions options = new();
Assert.False(options.ResolveUseHttps(null));
Environment.SetEnvironmentVariable("NEXUS_TLS_MODE", "https");
Assert.True(options.ResolveUseHttps(null));
}
}

View File

@ -0,0 +1,224 @@
using Robotico.Nexus.Client.Document;
using Robotico.Nexus.Client.Relational;
using Robotico.Nexus.Client.Tests.TestSupport;
using Robotico.Result.Errors;
using Xunit;
namespace Robotico.Nexus.Client.Tests;
public class NexusClientTests : IAsyncLifetime
{
private NexusTestHost _host = null!;
public async Task InitializeAsync() => _host = await NexusTestHost.StartAsync();
public async Task DisposeAsync() => await _host.DisposeAsync();
[Fact]
public void Connect_Fails_ForInvalidUri()
{
Robotico.Result.Result<NexusClient> result = NexusClient.Connect("not-a-uri");
Assert.True(result.IsError(out IError? error));
Assert.Equal(NexusErrors.UriInvalid, error!.Code);
}
[Fact]
public void Connect_Succeeds_AndBindsDefaultDatabase_WhenPathOmitted()
{
Robotico.Result.Result<NexusClient> result = NexusClient.Connect(_host.ConnectionUri(NexusUriSchemes.Graph));
Assert.True(result.IsSuccess(out NexusClient? client, out _));
using NexusClient c = client!;
Assert.Equal(NexusUriSchemes.Graph, c.Scheme);
Assert.Equal("pathways", c.Database);
}
[Fact]
public void Connect_Succeeds_AndBindsExplicitDatabase()
{
Robotico.Result.Result<NexusClient> result = NexusClient.Connect(_host.ConnectionUri(NexusUriSchemes.Time, "custom-db"));
Assert.True(result.IsSuccess(out NexusClient? client, out _));
using NexusClient c = client!;
Assert.Equal("custom-db", c.Database);
}
[Fact]
public void CheckHealth_Succeeds_WhenServerReady()
{
using NexusClient client = Connect(NexusUriSchemes.Graph);
_host.Health.Ready = true;
Robotico.Result.Result result = client.CheckHealth();
Assert.True(result.IsSuccess());
}
[Fact]
public void CheckHealth_Fails_WhenServerNotReady()
{
using NexusClient client = Connect(NexusUriSchemes.Graph);
_host.Health.Ready = false;
Robotico.Result.Result result = client.CheckHealth();
Assert.True(result.IsError(out IError? error));
Assert.Equal(NexusErrors.Unavailable, error!.Code);
}
[Fact]
public void CheckHealth_Fails_AfterDispose()
{
NexusClient client = Connect(NexusUriSchemes.Graph);
client.Dispose();
Robotico.Result.Result result = client.CheckHealth();
Assert.True(result.IsError(out IError? error));
Assert.Equal(NexusErrors.Disposed, error!.Code);
}
[Fact]
public void Dispose_IsIdempotent()
{
NexusClient client = Connect(NexusUriSchemes.Graph);
client.Dispose();
client.Dispose();
}
[Fact]
public async Task DisposeAsync_IsIdempotent_AndAfterSyncDispose()
{
NexusClient client = Connect(NexusUriSchemes.Graph);
await client.DisposeAsync();
await client.DisposeAsync();
NexusClient client2 = Connect(NexusUriSchemes.Time);
client2.Dispose();
await client2.DisposeAsync();
}
[Fact]
public void CreateDocumentClient_Succeeds_WhenNotDisposed()
{
using NexusClient client = Connect(NexusUriSchemes.Document);
Robotico.Result.Result<NexusDocumentClient> result = client.CreateDocumentClient();
Assert.True(result.IsSuccess(out NexusDocumentClient? documentClient, out _));
Assert.NotNull(documentClient);
}
[Fact]
public void CreateDocumentClient_Fails_AfterDispose()
{
NexusClient client = Connect(NexusUriSchemes.Document);
client.Dispose();
Robotico.Result.Result<NexusDocumentClient> result = client.CreateDocumentClient();
Assert.True(result.IsError(out IError? error));
Assert.Equal(NexusErrors.Disposed, error!.Code);
}
[Fact]
public void CreateRelationalClient_Succeeds_WhenNotDisposed()
{
using NexusClient client = Connect(NexusUriSchemes.Relational);
Robotico.Result.Result<NexusRelationalClient> result = client.CreateRelationalClient();
Assert.True(result.IsSuccess(out NexusRelationalClient? relationalClient, out _));
Assert.NotNull(relationalClient);
}
[Fact]
public void CreateRelationalClient_Fails_AfterDispose()
{
NexusClient client = Connect(NexusUriSchemes.Relational);
client.Dispose();
Robotico.Result.Result<NexusRelationalClient> result = client.CreateRelationalClient();
Assert.True(result.IsError(out IError? error));
Assert.Equal(NexusErrors.Disposed, error!.Code);
}
[Fact]
public void CreateDocumentWireClient_Succeeds_WhenNotDisposed()
{
using NexusClient client = Connect(NexusUriSchemes.Document);
Robotico.Result.Result<Mishima.Nexus.Wire.V1.NexusDocumentEngineService.NexusDocumentEngineServiceClient> result =
client.CreateDocumentWireClient();
Assert.True(result.IsSuccess());
}
[Fact]
public void CreateDocumentWireClient_Fails_AfterDispose()
{
NexusClient client = Connect(NexusUriSchemes.Document);
client.Dispose();
Robotico.Result.Result<Mishima.Nexus.Wire.V1.NexusDocumentEngineService.NexusDocumentEngineServiceClient> result =
client.CreateDocumentWireClient();
Assert.True(result.IsError(out IError? error));
Assert.Equal(NexusErrors.Disposed, error!.Code);
}
[Fact]
public void CreateRelationalWireClient_Succeeds_WhenNotDisposed()
{
using NexusClient client = Connect(NexusUriSchemes.Relational);
Robotico.Result.Result<Mishima.Nexus.Wire.V1.NexusRelationalEngineService.NexusRelationalEngineServiceClient> result =
client.CreateRelationalWireClient();
Assert.True(result.IsSuccess());
}
[Fact]
public void CreateRelationalWireClient_Fails_AfterDispose()
{
NexusClient client = Connect(NexusUriSchemes.Relational);
client.Dispose();
Robotico.Result.Result<Mishima.Nexus.Wire.V1.NexusRelationalEngineService.NexusRelationalEngineServiceClient> result =
client.CreateRelationalWireClient();
Assert.True(result.IsError(out IError? error));
Assert.Equal(NexusErrors.Disposed, error!.Code);
}
[Fact]
public void BuildCallMetadata_UsesBoundDatabase_WhenNoOverride()
{
using NexusClient client = Connect(NexusUriSchemes.Graph, "boundDb");
Grpc.Core.Metadata metadata = client.BuildCallMetadata();
Assert.Equal("boundDb", metadata.GetValue(NexusCallMetadata.DatabaseKey));
}
[Fact]
public void BuildCallMetadata_UsesOverride_WhenProvided()
{
using NexusClient client = Connect(NexusUriSchemes.Graph, "boundDb");
Grpc.Core.Metadata metadata = client.BuildCallMetadata("overrideDb");
Assert.Equal("overrideDb", metadata.GetValue(NexusCallMetadata.DatabaseKey));
}
private NexusClient Connect(string scheme, string? database = null)
{
Robotico.Result.Result<NexusClient> result = NexusClient.Connect(_host.ConnectionUri(scheme, database));
Assert.True(result.IsSuccess(out NexusClient? client, out _));
return client!;
}
}

View File

@ -0,0 +1,109 @@
using Robotico.Result.Errors;
using Xunit;
namespace Robotico.Nexus.Client.Tests;
public class NexusConnectionUriTests
{
[Fact]
public void TryParse_Rejects_EmptyString()
{
Robotico.Result.Result<NexusConnectionUriParts> result = NexusConnectionUri.TryParse(string.Empty);
Assert.True(result.IsError(out IError? error));
Assert.Equal(NexusErrors.UriInvalid, error!.Code);
}
[Fact]
public void TryParse_Rejects_WhitespaceOnly()
{
Robotico.Result.Result<NexusConnectionUriParts> result = NexusConnectionUri.TryParse(" ");
Assert.True(result.IsError(out IError? error));
Assert.Equal(NexusErrors.UriInvalid, error!.Code);
}
[Fact]
public void TryParse_Rejects_MalformedUri()
{
Robotico.Result.Result<NexusConnectionUriParts> result = NexusConnectionUri.TryParse("not a uri at all://");
Assert.True(result.IsError(out IError? error));
Assert.Equal(NexusErrors.UriInvalid, error!.Code);
}
[Fact]
public void TryParse_Rejects_SqlScheme_WithDedicatedMessage()
{
Robotico.Result.Result<NexusConnectionUriParts> result = NexusConnectionUri.TryParse("nexus+sql://localhost:50051/db");
Assert.True(result.IsError(out IError? error));
Assert.Equal(NexusErrors.UnsupportedScheme, error!.Code);
Assert.Contains("Mishima.Nexus.Sql", error.Message, StringComparison.Ordinal);
}
[Fact]
public void TryParse_Rejects_UnknownScheme()
{
Robotico.Result.Result<NexusConnectionUriParts> result = NexusConnectionUri.TryParse("http://localhost:50051/db");
Assert.True(result.IsError(out IError? error));
Assert.Equal(NexusErrors.UnsupportedScheme, error!.Code);
}
[Fact]
public void TryParse_Rejects_DefaultPort()
{
Robotico.Result.Result<NexusConnectionUriParts> result = NexusConnectionUri.TryParse("nexus+graph://localhost/db");
Assert.True(result.IsError(out IError? error));
Assert.Equal(NexusErrors.UriInvalid, error!.Code);
}
[Theory]
[InlineData("nexus+graph")]
[InlineData("nexus+time")]
[InlineData("nexus+document")]
[InlineData("nexus+relational")]
public void TryParse_Accepts_AllSupportedSchemes_WithExplicitPortAndPath(string scheme)
{
Robotico.Result.Result<NexusConnectionUriParts> result = NexusConnectionUri.TryParse($"{scheme}://myhost:50051/mydb");
Assert.True(result.IsSuccess(out NexusConnectionUriParts? parsed, out _));
Assert.Equal(scheme, parsed!.Scheme);
Assert.Equal("myhost", parsed.Host);
Assert.Equal(50051, parsed.Port);
Assert.Equal("mydb", parsed.Database);
}
[Theory]
[InlineData("nexus+graph://myhost:50051")]
[InlineData("nexus+graph://myhost:50051/")]
public void TryParse_Database_IsNull_WhenPathIsEmptyOrRoot(string uri)
{
Robotico.Result.Result<NexusConnectionUriParts> result = NexusConnectionUri.TryParse(uri);
Assert.True(result.IsSuccess(out NexusConnectionUriParts? parsed, out _));
Assert.Null(parsed!.Database);
}
[Fact]
public void TryParse_TrimsLeadingSlash_FromDatabasePath()
{
Robotico.Result.Result<NexusConnectionUriParts> result = NexusConnectionUri.TryParse("nexus+time://myhost:50051/telemetry");
Assert.True(result.IsSuccess(out NexusConnectionUriParts? parsed, out _));
Assert.Equal("telemetry", parsed!.Database);
}
[Theory]
[InlineData(NexusUriSchemes.Graph, "pathways")]
[InlineData(NexusUriSchemes.Time, "telemetry")]
[InlineData(NexusUriSchemes.Document, "catalog")]
[InlineData(NexusUriSchemes.Relational, "ledger")]
[InlineData("unknown-scheme", "primary")]
public void DefaultDatabase_ReturnsCanonicalNamePerScheme(string scheme, string expected)
{
Assert.Equal(expected, NexusConnectionUri.DefaultDatabase(scheme));
}
}

View File

@ -0,0 +1,24 @@
using Robotico.Result.Errors;
using Xunit;
namespace Robotico.Nexus.Client.Tests;
public class NexusErrorsTests
{
[Fact]
public void Error_WithDefaultCode_UsesNexusCode()
{
SimpleError error = NexusErrors.Error("boom");
Assert.Equal(NexusErrors.Code, error.Code);
Assert.Equal("boom", error.Message);
}
[Fact]
public void Error_WithExplicitCode_UsesThatCode()
{
SimpleError error = NexusErrors.Error("boom", NexusErrors.NotFound);
Assert.Equal(NexusErrors.NotFound, error.Code);
}
}

View File

@ -0,0 +1,47 @@
using Grpc.Net.Client;
using Robotico.Result.Errors;
using Xunit;
namespace Robotico.Nexus.Client.Tests;
public class NexusGrpcChannelFactoryTests
{
[Fact]
public void ResolveAddress_UsesHttps_WhenRequested()
{
NexusConnectionUriParts parsed = new(NexusUriSchemes.Graph, "myhost", 50051, null);
Assert.Equal("https://myhost:50051", NexusGrpcChannelFactory.ResolveAddress(parsed, useHttps: true));
}
[Fact]
public void ResolveAddress_UsesHttp_WhenPlaintext()
{
NexusConnectionUriParts parsed = new(NexusUriSchemes.Graph, "myhost", 50051, null);
Assert.Equal("http://myhost:50051", NexusGrpcChannelFactory.ResolveAddress(parsed, useHttps: false));
}
[Fact]
public void CreateChannel_Succeeds_ForPlaintextLoopback()
{
NexusConnectionUriParts parsed = new(NexusUriSchemes.Graph, "127.0.0.1", 50051, null);
Robotico.Result.Result<GrpcChannel> result = NexusGrpcChannelFactory.CreateChannel(parsed, material: null, useHttps: false);
Assert.True(result.IsSuccess(out GrpcChannel? channel, out _));
channel!.Dispose();
}
[Fact]
public void CreateChannel_Fails_WhenTlsMaterialFilesAreMissing()
{
NexusConnectionUriParts parsed = new(NexusUriSchemes.Graph, "127.0.0.1", 50051, null);
NexusTlsMaterial material = new("/nonexistent/ca.pem", "/nonexistent/cert.pem", "/nonexistent/key.pem");
Robotico.Result.Result<GrpcChannel> result = NexusGrpcChannelFactory.CreateChannel(parsed, material, useHttps: true);
Assert.True(result.IsError(out IError? error));
Assert.Equal(NexusErrors.TlsRejected, error!.Code);
}
}

View File

@ -0,0 +1,113 @@
using Xunit;
namespace Robotico.Nexus.Client.Tests;
[Collection("NexusTlsEnvironment")]
public class NexusTlsMaterialTests : IDisposable
{
private const string CaVar = "NEXUS_TLS_CA_FILE";
private const string CertVar = "NEXUS_TLS_CLIENT_CERT_FILE";
private const string KeyVar = "NEXUS_TLS_CLIENT_KEY_FILE";
private const string ModeVar = "NEXUS_TLS_MODE";
public void Dispose()
{
Environment.SetEnvironmentVariable(CaVar, null);
Environment.SetEnvironmentVariable(CertVar, null);
Environment.SetEnvironmentVariable(KeyVar, null);
Environment.SetEnvironmentVariable(ModeVar, null);
}
[Fact]
public void Constructor_ThrowsOnBlankArguments()
{
Assert.Throws<ArgumentException>(() => new NexusTlsMaterial(string.Empty, "cert", "key"));
Assert.Throws<ArgumentException>(() => new NexusTlsMaterial("ca", string.Empty, "key"));
Assert.Throws<ArgumentException>(() => new NexusTlsMaterial("ca", "cert", string.Empty));
}
[Fact]
public void Constructor_ResolvesFullPaths_AndDefaultsToHttps()
{
NexusTlsMaterial material = new("ca.pem", "cert.pem", "key.pem");
Assert.EndsWith("ca.pem", material.CaCertificateFile, StringComparison.Ordinal);
Assert.True(Path.IsPathRooted(material.CaCertificateFile));
Assert.True(material.UseHttps);
}
[Fact]
public void TryFromEnvironment_ReturnsNull_WhenVariablesUnset()
{
Assert.Null(NexusTlsMaterial.TryFromEnvironment());
}
[Fact]
public void TryFromEnvironment_ReturnsNull_WhenFilesDoNotExist()
{
Environment.SetEnvironmentVariable(CaVar, "/nonexistent/ca.pem");
Environment.SetEnvironmentVariable(CertVar, "/nonexistent/cert.pem");
Environment.SetEnvironmentVariable(KeyVar, "/nonexistent/key.pem");
Assert.Null(NexusTlsMaterial.TryFromEnvironment());
}
[Fact]
public void TryFromEnvironment_ReturnsMaterial_WhenFilesExist()
{
string dir = Directory.CreateTempSubdirectory("nexus-tls-test-").FullName;
try
{
string ca = Path.Combine(dir, "ca.pem");
string cert = Path.Combine(dir, "cert.pem");
string key = Path.Combine(dir, "key.pem");
File.WriteAllText(ca, "ca");
File.WriteAllText(cert, "cert");
File.WriteAllText(key, "key");
Environment.SetEnvironmentVariable(CaVar, ca);
Environment.SetEnvironmentVariable(CertVar, cert);
Environment.SetEnvironmentVariable(KeyVar, key);
Environment.SetEnvironmentVariable(ModeVar, "insecure");
NexusTlsMaterial? material = NexusTlsMaterial.TryFromEnvironment();
Assert.NotNull(material);
Assert.False(material!.UseHttps);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void EnsureFilesExist_Throws_WhenAnyFileMissing()
{
NexusTlsMaterial material = new("/nonexistent/ca.pem", "/nonexistent/cert.pem", "/nonexistent/key.pem");
Assert.Throws<InvalidOperationException>(material.EnsureFilesExist);
}
[Fact]
public void EnsureFilesExist_Succeeds_WhenAllFilesPresent()
{
string dir = Directory.CreateTempSubdirectory("nexus-tls-test-").FullName;
try
{
string ca = Path.Combine(dir, "ca.pem");
string cert = Path.Combine(dir, "cert.pem");
string key = Path.Combine(dir, "key.pem");
File.WriteAllText(ca, "ca");
File.WriteAllText(cert, "cert");
File.WriteAllText(key, "key");
NexusTlsMaterial material = new(ca, cert, key);
material.EnsureFilesExist();
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
}

View File

@ -0,0 +1,115 @@
using Grpc.Core;
using Mishima.Nexus.Wire.V1;
using Robotico.Result.Errors;
using Xunit;
namespace Robotico.Nexus.Client.Tests;
public class NexusWireStatusTests
{
[Fact]
public void ToResult_Succeeds_WhenStatusIsOk()
{
Robotico.Result.Result result = NexusWireStatus.ToResult(new NexusStatus { Code = "OK" });
Assert.True(result.IsSuccess());
}
[Fact]
public void ToResult_Fails_WhenStatusIsNull()
{
Robotico.Result.Result result = NexusWireStatus.ToResult((NexusStatus?)null);
Assert.True(result.IsError(out IError? error));
Assert.Equal(NexusErrors.WireFailed, error!.Code);
}
[Fact]
public void ToResult_Fails_WithCodeAndMessage_FromStatus()
{
Robotico.Result.Result result = NexusWireStatus.ToResult(new NexusStatus { Code = "NXS_FOO", Message = "boom" });
Assert.True(result.IsError(out IError? error));
Assert.Equal("NXS_FOO", error!.Code);
Assert.Equal("boom", error.Message);
}
[Fact]
public void ToResult_Fails_WithFallbackCodeAndMessage_WhenBlank()
{
Robotico.Result.Result result = NexusWireStatus.ToResult(new NexusStatus { Code = string.Empty, Message = string.Empty });
Assert.True(result.IsError(out IError? error));
Assert.Equal(NexusErrors.WireFailed, error!.Code);
Assert.Equal("Nexus call failed.", error.Message);
}
[Fact]
public void ToResultGeneric_CarriesValue_OnSuccess()
{
Robotico.Result.Result<int> result = NexusWireStatus.ToResult(new NexusStatus { Code = "OK" }, 42);
Assert.True(result.IsSuccess(out int value, out _));
Assert.Equal(42, value);
}
[Fact]
public void ToResultGeneric_PropagatesError_OnFailure()
{
Robotico.Result.Result<int> result = NexusWireStatus.ToResult(new NexusStatus { Code = "NXS_BAD", Message = "bad" }, 42);
Assert.True(result.IsError(out IError? error));
Assert.Equal("NXS_BAD", error!.Code);
}
[Fact]
public void IsOk_True_ForOkStatus()
{
Assert.True(NexusWireStatus.IsOk(new NexusStatus { Code = "OK" }));
}
[Fact]
public void IsOk_False_ForNullOrNonOkStatus()
{
Assert.False(NexusWireStatus.IsOk(null));
Assert.False(NexusWireStatus.IsOk(new NexusStatus { Code = "NXS_BAD" }));
}
[Theory]
[InlineData(StatusCode.OK, NexusErrors.WireFailed)]
[InlineData(StatusCode.Cancelled, NexusErrors.Unavailable)]
[InlineData(StatusCode.Unknown, NexusErrors.WireFailed)]
[InlineData(StatusCode.InvalidArgument, NexusErrors.InvalidArgument)]
[InlineData(StatusCode.DeadlineExceeded, NexusErrors.Unavailable)]
[InlineData(StatusCode.NotFound, NexusErrors.NotFound)]
[InlineData(StatusCode.AlreadyExists, NexusErrors.AlreadyExists)]
[InlineData(StatusCode.PermissionDenied, NexusErrors.WireFailed)]
[InlineData(StatusCode.ResourceExhausted, NexusErrors.Unavailable)]
[InlineData(StatusCode.FailedPrecondition, NexusErrors.InvalidArgument)]
[InlineData(StatusCode.Aborted, NexusErrors.WireFailed)]
[InlineData(StatusCode.OutOfRange, NexusErrors.InvalidArgument)]
[InlineData(StatusCode.Unimplemented, NexusErrors.WireFailed)]
[InlineData(StatusCode.Internal, NexusErrors.WireFailed)]
[InlineData(StatusCode.Unavailable, NexusErrors.Unavailable)]
[InlineData(StatusCode.DataLoss, NexusErrors.WireFailed)]
[InlineData(StatusCode.Unauthenticated, NexusErrors.WireFailed)]
public void FromException_MapsEveryGrpcStatusCode(StatusCode statusCode, string expectedCode)
{
RpcException exception = new(new Status(statusCode, "detail message"));
IError error = NexusWireStatus.FromException(exception);
Assert.Equal(expectedCode, error.Code);
Assert.Contains("detail message", error.Message, StringComparison.Ordinal);
}
[Fact]
public void FromException_FallsBackTo_ExceptionMessage_WhenDetailBlank()
{
RpcException exception = new(new Status(StatusCode.Internal, string.Empty));
IError error = NexusWireStatus.FromException(exception);
Assert.Contains(exception.Message, error.Message, StringComparison.Ordinal);
}
}

View File

@ -0,0 +1,70 @@
using Robotico.Nexus.Client.Relational;
using Robotico.Nexus.Client.Tests.TestSupport;
using Robotico.Result.Errors;
using Xunit;
namespace Robotico.Nexus.Client.Tests.Relational;
public class NexusRelationalClientTests : IAsyncLifetime
{
private NexusTestHost _host = null!;
private NexusClient _client = null!;
private NexusRelationalClient _relationalClient = null!;
public async Task InitializeAsync()
{
_host = await NexusTestHost.StartAsync();
Robotico.Result.Result<NexusClient> connected = NexusClient.Connect(_host.ConnectionUri(NexusUriSchemes.Relational));
Assert.True(connected.IsSuccess(out NexusClient? client, out _));
_client = client!;
Robotico.Result.Result<NexusRelationalClient> created = _client.CreateRelationalClient();
Assert.True(created.IsSuccess(out NexusRelationalClient? relationalClient, out _));
_relationalClient = relationalClient!;
}
public async Task DisposeAsync()
{
_client.Dispose();
await _host.DisposeAsync();
}
[Fact]
public void CheckHealth_Succeeds()
{
Robotico.Result.Result result = _relationalClient.CheckHealth();
Assert.True(result.IsSuccess());
}
[Fact]
public void CheckHealth_Fails_WhenEngineReportsFailure()
{
Robotico.Result.Result<NexusClient> connected = NexusClient.Connect(
_host.ConnectionUri(NexusUriSchemes.Relational, FakeNexusTriggers.PingReportsFailure));
Assert.True(connected.IsSuccess(out NexusClient? client, out _));
using NexusClient failingClient = client!;
Robotico.Result.Result<NexusRelationalClient> created = failingClient.CreateRelationalClient();
Assert.True(created.IsSuccess(out NexusRelationalClient? failingRelationalClient, out _));
Robotico.Result.Result result = failingRelationalClient!.CheckHealth();
Assert.True(result.IsError(out IError? error));
Assert.Equal(NexusErrors.Unavailable, error!.Code);
}
[Fact]
public void CheckHealth_Fails_WhenTransportThrows()
{
Robotico.Result.Result<NexusClient> connected = NexusClient.Connect(
_host.ConnectionUri(NexusUriSchemes.Relational, FakeNexusTriggers.PingThrows));
Assert.True(connected.IsSuccess(out NexusClient? client, out _));
using NexusClient failingClient = client!;
Robotico.Result.Result<NexusRelationalClient> created = failingClient.CreateRelationalClient();
Assert.True(created.IsSuccess(out NexusRelationalClient? failingRelationalClient, out _));
Robotico.Result.Result result = failingRelationalClient!.CheckHealth();
Assert.True(result.IsError(out IError? error));
Assert.Equal(NexusErrors.Unavailable, error!.Code);
}
}

View File

@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>Robotico.Nexus.Client.Tests</RootNamespace>
<AssemblyName>Robotico.Nexus.Client.Tests</AssemblyName>
<PackageId>Robotico.Nexus.Client.Tests</PackageId>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<TargetFrameworks>net10.0</TargetFrameworks>
<NoWarn>$(NoWarn);CA1054;CA1055;CA1062;CA1063;CA1707;CA1515;CA1711;CA1816;CA1849;CA1861;CA2000;CA2007;CA2234;IDE0004;IDE0005;IDE0022;IDE0090;MA0004;MA0040;MA0042;EnableGenerateDocumentationFile</NoWarn>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Robotico.Nexus.Client\Robotico.Nexus.Client.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,45 @@
using Grpc.Core;
using Mishima.Nexus.Wire.V1;
namespace Robotico.Nexus.Client.Tests.TestSupport;
/// <summary>
/// In-memory fake standing in for a real Nexus document engine host — only the RPCs used by
/// <see cref="Robotico.Nexus.Client.Document.NexusDocumentClient"/> (Ping + single-document CRUD).
/// </summary>
public sealed class FakeDocumentEngineService : NexusDocumentEngineService.NexusDocumentEngineServiceBase
{
private readonly Dictionary<(string Collection, string Id), string> _documents = [];
/// <inheritdoc />
public override Task<DocumentPingResponse> Ping(DocumentPingRequest request, ServerCallContext context)
{
NexusStatus? failure = FakeNexusTriggers.TryStatusFailure(context);
return Task.FromResult(new DocumentPingResponse { Status = failure ?? NexusStatuses.Ok, EngineVersion = "fake" });
}
/// <inheritdoc />
public override Task<DocumentUpsertResponse> UpsertDocument(DocumentUpsertRequest request, ServerCallContext context)
{
_documents[(request.CollectionName, request.DocumentId)] = request.JsonBody;
return Task.FromResult(new DocumentUpsertResponse { Status = NexusStatuses.Ok });
}
/// <inheritdoc />
public override Task<DocumentGetResponse> GetDocument(DocumentGetRequest request, ServerCallContext context)
{
if (_documents.TryGetValue((request.CollectionName, request.DocumentId), out string? body))
{
return Task.FromResult(new DocumentGetResponse { Status = NexusStatuses.Ok, Found = true, JsonBody = body });
}
return Task.FromResult(new DocumentGetResponse { Status = NexusStatuses.Ok, Found = false });
}
/// <inheritdoc />
public override Task<DocumentDeleteResponse> DeleteDocument(DocumentDeleteRequest request, ServerCallContext context)
{
_documents.Remove((request.CollectionName, request.DocumentId));
return Task.FromResult(new DocumentDeleteResponse { Status = NexusStatuses.Ok });
}
}

View File

@ -0,0 +1,137 @@
using Grpc.Core;
using Mishima.Nexus.Wire.V1;
namespace Robotico.Nexus.Client.Tests.TestSupport;
/// <summary>
/// In-memory fake standing in for a real Nexus graph engine host, exercising the exact wire
/// contract <see cref="Robotico.Nexus.Client.Graph.NexusGraphStore"/> / <c>NexusGraphSession</c>
/// depend on. <c>Ping</c> honors <see cref="FakeNexusTriggers"/> database-name sentinels so tests
/// can drive both the happy path and the failure branches of the client.
/// </summary>
public sealed class FakeGraphEngineService : NexusGraphEngineService.NexusGraphEngineServiceBase
{
private readonly Dictionary<ulong, List<string>> _vertexLabels = [];
private readonly HashSet<string> _registeredLabels = [];
private readonly HashSet<string> _registeredEdgeTypes = [];
private readonly HashSet<(ulong Source, ulong Target, string EdgeType)> _edges = [];
private ulong _txn;
/// <inheritdoc />
public override Task<GraphPingResponse> Ping(GraphPingRequest request, ServerCallContext context)
{
NexusStatus? failure = FakeNexusTriggers.TryStatusFailure(context);
if (failure is not null)
{
return Task.FromResult(new GraphPingResponse { Status = failure, EngineVersion = "fake" });
}
return Task.FromResult(new GraphPingResponse { Status = NexusStatuses.Ok, EngineVersion = "fake" });
}
/// <inheritdoc />
public override Task<GraphRegisterLabelResponse> RegisterLabel(GraphRegisterLabelRequest request, ServerCallContext context)
{
_registeredLabels.Add(request.LabelName);
return Task.FromResult(new GraphRegisterLabelResponse { Status = NexusStatuses.Ok });
}
/// <inheritdoc />
public override Task<GraphRegisterEdgeTypeResponse> RegisterEdgeType(GraphRegisterEdgeTypeRequest request, ServerCallContext context)
{
_registeredEdgeTypes.Add(request.EdgeTypeName);
return Task.FromResult(new GraphRegisterEdgeTypeResponse { Status = NexusStatuses.Ok });
}
/// <inheritdoc />
public override Task<GraphUpsertVertexResponse> UpsertVertex(GraphUpsertVertexRequest request, ServerCallContext context)
{
_vertexLabels[request.VertexId] = [.. request.LabelNames];
return Task.FromResult(new GraphUpsertVertexResponse { Status = NexusStatuses.Ok, CommittedTxnId = ++_txn });
}
/// <inheritdoc />
public override Task<GraphDeleteVertexResponse> DeleteVertex(GraphDeleteVertexRequest request, ServerCallContext context)
{
_vertexLabels.Remove(request.VertexId);
return Task.FromResult(new GraphDeleteVertexResponse { Status = NexusStatuses.Ok, CommittedTxnId = ++_txn });
}
/// <inheritdoc />
public override Task<GraphGetVertexResponse> GetVertex(GraphGetVertexRequest request, ServerCallContext context)
{
if (!_vertexLabels.TryGetValue(request.VertexId, out List<string>? labels))
{
return Task.FromResult(new GraphGetVertexResponse { Status = NexusStatuses.NotFound($"Vertex {request.VertexId} not found.") });
}
GraphGetVertexResponse response = new() { Status = NexusStatuses.Ok, VertexId = request.VertexId };
response.LabelNames.AddRange(labels);
return Task.FromResult(response);
}
/// <inheritdoc />
public override Task<GraphUpsertEdgeResponse> UpsertEdge(GraphUpsertEdgeRequest request, ServerCallContext context)
{
_edges.Add((request.SourceVertexId, request.TargetVertexId, request.EdgeTypeName));
return Task.FromResult(new GraphUpsertEdgeResponse { Status = NexusStatuses.Ok, CommittedTxnId = ++_txn });
}
/// <inheritdoc />
public override Task<GraphDeleteEdgeResponse> DeleteEdge(GraphDeleteEdgeRequest request, ServerCallContext context)
{
_edges.Remove((request.SourceVertexId, request.TargetVertexId, request.EdgeTypeName));
return Task.FromResult(new GraphDeleteEdgeResponse { Status = NexusStatuses.Ok, CommittedTxnId = ++_txn });
}
/// <inheritdoc />
public override Task<GraphGetEdgeResponse> GetEdge(GraphGetEdgeRequest request, ServerCallContext context)
{
(ulong, ulong, string) key = (request.SourceVertexId, request.TargetVertexId, request.EdgeTypeName);
if (!_edges.Contains(key))
{
return Task.FromResult(new GraphGetEdgeResponse { Status = NexusStatuses.NotFound("Edge not found.") });
}
return Task.FromResult(new GraphGetEdgeResponse
{
Status = NexusStatuses.Ok,
SourceVertexId = request.SourceVertexId,
TargetVertexId = request.TargetVertexId,
EdgeTypeName = request.EdgeTypeName,
});
}
/// <inheritdoc />
public override Task<GraphBreadthFirstResponse> BreadthFirst(GraphBreadthFirstRequest request, ServerCallContext context)
{
GraphBreadthFirstResponse response = new() { Status = NexusStatuses.Ok };
response.VisitOrder.Add(request.StartVertexId);
response.DistanceByVertex.Add(request.StartVertexId, 0);
return Task.FromResult(response);
}
/// <inheritdoc />
public override Task<GraphListVerticesByLabelResponse> ListVerticesByLabel(GraphListVerticesByLabelRequest request, ServerCallContext context)
{
List<GraphVertexWire> matches = [.. _vertexLabels
.Where(pair => pair.Value.Contains(request.LabelName))
.Select(pair =>
{
GraphVertexWire wire = new() { VertexId = pair.Key };
wire.LabelNames.AddRange(pair.Value);
return wire;
})];
GraphListVerticesByLabelResponse response = new()
{
Status = NexusStatuses.Ok,
LabelName = request.LabelName,
TotalMatching = matches.Count,
Skip = request.Skip,
Limit = request.Limit,
};
response.Vertices.AddRange(matches.Skip(request.Skip).Take(request.Limit is 0 ? matches.Count : request.Limit));
return Task.FromResult(response);
}
}

View File

@ -0,0 +1,19 @@
using Grpc.Core;
using Mishima.Nexus.Wire.V1;
namespace Robotico.Nexus.Client.Tests.TestSupport;
/// <summary>In-memory fake standing in for <c>NexusHealthService</c>.</summary>
public sealed class FakeHealthService : NexusHealthService.NexusHealthServiceBase
{
/// <summary>When <see langword="false"/>, <see cref="Check"/> reports the server as not ready.</summary>
public bool Ready { get; set; } = true;
/// <inheritdoc />
public override Task<HealthCheckResponse> Check(HealthCheckRequest request, ServerCallContext context)
{
HealthCheckResponse response = new() { Ready = Ready };
response.Planes.Add(new EnginePlaneHealth { Plane = "graph", Ready = Ready, Detail = Ready ? "ok" : "simulated down" });
return Task.FromResult(response);
}
}

View File

@ -0,0 +1,32 @@
using Grpc.Core;
using Mishima.Nexus.Wire.V1;
namespace Robotico.Nexus.Client.Tests.TestSupport;
/// <summary>
/// Sentinel logical-database names (sent via the <c>x-nexus-database</c> metadata header) that let
/// tests deterministically drive the failure branches of <c>Ping</c>-fronted RPCs without a real
/// unreachable engine.
/// </summary>
public static class FakeNexusTriggers
{
/// <summary>Ping succeeds at the transport level but the engine reports a non-OK status.</summary>
public const string PingReportsFailure = "trigger-ping-status-failure";
/// <summary>Ping fails at the transport level (simulated <see cref="RpcException"/>).</summary>
public const string PingThrows = "trigger-ping-rpc-exception";
/// <summary>Returns a failure <see cref="NexusStatus"/> when the request's database header is <see cref="PingReportsFailure"/>, or throws when it is <see cref="PingThrows"/>.</summary>
public static NexusStatus? TryStatusFailure(ServerCallContext context)
{
string? database = context.RequestHeaders.GetValue(NexusCallMetadata.DatabaseKey);
if (string.Equals(database, PingThrows, StringComparison.Ordinal))
{
throw new RpcException(new Status(StatusCode.Unavailable, "Simulated transport failure."));
}
return string.Equals(database, PingReportsFailure, StringComparison.Ordinal)
? NexusStatuses.Failure("NXS_UNAVAILABLE", "Simulated engine-reported failure.")
: null;
}
}

View File

@ -0,0 +1,18 @@
using Grpc.Core;
using Mishima.Nexus.Wire.V1;
namespace Robotico.Nexus.Client.Tests.TestSupport;
/// <summary>
/// In-memory fake standing in for a real Nexus relational engine host — only <c>Ping</c>, the sole
/// RPC used by <see cref="Robotico.Nexus.Client.Relational.NexusRelationalClient"/>.
/// </summary>
public sealed class FakeRelationalEngineService : NexusRelationalEngineService.NexusRelationalEngineServiceBase
{
/// <inheritdoc />
public override Task<RelationalPingResponse> Ping(RelationalPingRequest request, ServerCallContext context)
{
NexusStatus? failure = FakeNexusTriggers.TryStatusFailure(context);
return Task.FromResult(new RelationalPingResponse { Status = failure ?? NexusStatuses.Ok, EngineVersion = "fake" });
}
}

View File

@ -0,0 +1,166 @@
using Grpc.Core;
using Mishima.Nexus.Wire.V1;
namespace Robotico.Nexus.Client.Tests.TestSupport;
/// <summary>In-memory fake standing in for a real Nexus time engine host.</summary>
public sealed class FakeTimeEngineService : NexusTimeEngineService.NexusTimeEngineServiceBase
{
private readonly Dictionary<ulong, List<TimeSampleWire>> _samplesBySeries = [];
private readonly Dictionary<ulong, List<TimeWideEventWire>> _eventsByTable = [];
private readonly Dictionary<ulong, List<TimeEventFieldWire>> _fieldsByTable = [];
private ulong _nextSeriesId = 1;
private ulong _nextTableId = 1;
private ulong _nextEventId = 1;
/// <inheritdoc />
public override Task<TimePingResponse> Ping(TimePingRequest request, ServerCallContext context)
{
NexusStatus? failure = FakeNexusTriggers.TryStatusFailure(context);
return Task.FromResult(new TimePingResponse { Status = failure ?? NexusStatuses.Ok, EngineVersion = "fake" });
}
/// <inheritdoc />
public override Task<TimeDoctorResponse> Doctor(TimeDoctorRequest request, ServerCallContext context) =>
Task.FromResult(new TimeDoctorResponse
{
Status = NexusStatuses.Ok,
ActiveSeriesCount = _samplesBySeries.Count,
TotalSampleCount = _samplesBySeries.Values.Sum(list => list.Count),
ActiveEventTableCount = _eventsByTable.Count,
TotalEventCount = _eventsByTable.Values.Sum(list => list.Count),
PrimaryLengthBytes = 4096,
WalLengthBytes = 0,
PrimaryFormatVersion = 1,
WalFormatVersion = 1,
});
/// <inheritdoc />
public override Task<TimeRegisterSeriesResponse> RegisterSeries(TimeRegisterSeriesRequest request, ServerCallContext context)
{
ulong id = _nextSeriesId++;
_samplesBySeries[id] = [];
return Task.FromResult(new TimeRegisterSeriesResponse { Status = NexusStatuses.Ok, SeriesId = id });
}
/// <inheritdoc />
public override Task<TimeAppendSampleResponse> AppendSample(TimeAppendSampleRequest request, ServerCallContext context)
{
if (!_samplesBySeries.TryGetValue(request.SeriesId, out List<TimeSampleWire>? samples))
{
return Task.FromResult(new TimeAppendSampleResponse { Status = NexusStatuses.NotFound($"Series {request.SeriesId} not registered.") });
}
samples.Add(new TimeSampleWire { TimestampUnixNanos = request.TimestampUnixNanos, Value = request.Value });
return Task.FromResult(new TimeAppendSampleResponse { Status = NexusStatuses.Ok });
}
/// <inheritdoc />
public override Task<TimeAppendSampleResponse> AppendSampleBatch(TimeAppendSampleBatchRequest request, ServerCallContext context)
{
if (!_samplesBySeries.TryGetValue(request.SeriesId, out List<TimeSampleWire>? samples))
{
return Task.FromResult(new TimeAppendSampleResponse { Status = NexusStatuses.NotFound($"Series {request.SeriesId} not registered.") });
}
samples.AddRange(request.Samples);
return Task.FromResult(new TimeAppendSampleResponse { Status = NexusStatuses.Ok });
}
/// <inheritdoc />
public override Task<TimeQueryRangeResponse> QueryRange(TimeQueryRangeRequest request, ServerCallContext context)
{
TimeQueryRangeResponse response = new() { Status = NexusStatuses.Ok };
if (_samplesBySeries.TryGetValue(request.SeriesId, out List<TimeSampleWire>? samples))
{
IEnumerable<TimeSampleWire> matching = samples
.Where(s => s.TimestampUnixNanos >= request.FromInclusive && s.TimestampUnixNanos < request.ToExclusive)
.Take(request.Limit is 0 ? int.MaxValue : request.Limit);
response.Samples.AddRange(matching);
}
return Task.FromResult(response);
}
/// <inheritdoc />
public override Task<TimeLastResponse> Last(TimeLastRequest request, ServerCallContext context)
{
if (_samplesBySeries.TryGetValue(request.SeriesId, out List<TimeSampleWire>? samples) && samples.Count > 0)
{
return Task.FromResult(new TimeLastResponse { Status = NexusStatuses.Ok, HasSample = true, Sample = samples[^1] });
}
return Task.FromResult(new TimeLastResponse { Status = NexusStatuses.Ok, HasSample = false });
}
/// <inheritdoc />
public override Task<TimeRegisterEventTableResponse> RegisterEventTable(TimeRegisterEventTableRequest request, ServerCallContext context)
{
ulong id = _nextTableId++;
_eventsByTable[id] = [];
_fieldsByTable[id] = [.. request.Fields];
return Task.FromResult(new TimeRegisterEventTableResponse { Status = NexusStatuses.Ok, TableId = id });
}
/// <inheritdoc />
public override Task<TimeAppendEventResponse> AppendEvent(TimeAppendEventRequest request, ServerCallContext context)
{
if (!_eventsByTable.TryGetValue(request.TableId, out List<TimeWideEventWire>? events))
{
return Task.FromResult(new TimeAppendEventResponse { Status = NexusStatuses.NotFound($"Table {request.TableId} not registered.") });
}
ulong eventId = _nextEventId++;
TimeWideEventWire wire = new() { EventId = eventId, TimestampUnixNanos = request.TimestampUnixNanos };
wire.Cells.AddRange(request.Cells);
events.Add(wire);
return Task.FromResult(new TimeAppendEventResponse { Status = NexusStatuses.Ok, EventId = eventId });
}
/// <inheritdoc />
public override Task<TimeQueryEventsResponse> QueryEvents(TimeQueryEventsRequest request, ServerCallContext context)
{
TimeQueryEventsResponse response = new() { Status = NexusStatuses.Ok };
if (_eventsByTable.TryGetValue(request.TableId, out List<TimeWideEventWire>? events))
{
IEnumerable<TimeWideEventWire> matching = events
.Where(e => e.TimestampUnixNanos >= request.FromInclusive && e.TimestampUnixNanos < request.ToExclusive)
.Take(request.Limit is 0 ? int.MaxValue : request.Limit);
response.Events.AddRange(matching);
}
return Task.FromResult(response);
}
/// <inheritdoc />
public override Task<TimeQueryEventsResponse> LookupInverted(TimeLookupInvertedRequest request, ServerCallContext context)
{
TimeQueryEventsResponse response = new() { Status = NexusStatuses.Ok };
if (_eventsByTable.TryGetValue(request.TableId, out List<TimeWideEventWire>? events))
{
IEnumerable<TimeWideEventWire> matching = events
.Where(e => request.FieldOrdinal < e.Cells.Count && string.Equals(e.Cells[(int)request.FieldOrdinal].Utf8, request.ExactValue, StringComparison.Ordinal))
.Take(request.Limit is 0 ? int.MaxValue : request.Limit);
response.Events.AddRange(matching);
}
return Task.FromResult(response);
}
/// <inheritdoc />
public override Task<TimeSearchFullTextResponse> SearchFullText(TimeSearchFullTextRequest request, ServerCallContext context)
{
TimeSearchFullTextResponse response = new() { Status = NexusStatuses.Ok };
if (_eventsByTable.TryGetValue(request.TableId, out List<TimeWideEventWire>? events))
{
IEnumerable<TimeSearchHitWire> matching = events
.Where(e => request.FieldOrdinal < e.Cells.Count
&& e.Cells[(int)request.FieldOrdinal].Utf8.Contains(request.Query, StringComparison.OrdinalIgnoreCase))
.Select(e => new TimeSearchHitWire { EventId = e.EventId, TimestampUnixNanos = e.TimestampUnixNanos, Score = 1.0 })
.Take(request.Limit is 0 ? int.MaxValue : request.Limit);
response.Hits.AddRange(matching);
}
return Task.FromResult(response);
}
}

View File

@ -0,0 +1,16 @@
using Mishima.Nexus.Wire.V1;
namespace Robotico.Nexus.Client.Tests.TestSupport;
/// <summary>Shared <see cref="NexusStatus"/> envelope builders for fake engine services.</summary>
public static class NexusStatuses
{
/// <summary>A fresh success envelope (mirrors the real wire's "OK" convention).</summary>
public static NexusStatus Ok => new() { Code = "OK", Message = string.Empty };
/// <summary>A coded failure envelope.</summary>
public static NexusStatus Failure(string code, string message) => new() { Code = code, Message = message };
/// <summary>A not-found failure envelope.</summary>
public static NexusStatus NotFound(string message) => Failure("NXS_NOT_FOUND", message);
}

View File

@ -0,0 +1,72 @@
using System.Net;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Robotico.Nexus.Client.Tests.TestSupport;
/// <summary>
/// Loopback Kestrel host mapping the fake graph/time/document/relational/health gRPC services, so
/// tests exercise the real wire (HTTP/2, protobuf, gRPC status codes) without a live Nexus process.
/// </summary>
public sealed class NexusTestHost : IAsyncDisposable
{
private readonly WebApplication _app;
private NexusTestHost(WebApplication app, int port, FakeHealthService health)
{
_app = app;
Port = port;
Health = health;
}
/// <summary>Loopback port Kestrel bound to.</summary>
public int Port { get; }
/// <summary>The health fake, so tests can flip <see cref="FakeHealthService.Ready"/>.</summary>
public FakeHealthService Health { get; }
/// <summary>Starts the host on an OS-assigned loopback port.</summary>
public static async Task<NexusTestHost> StartAsync()
{
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.Logging.ClearProviders();
builder.WebHost.ConfigureKestrel(options =>
{
options.Listen(IPAddress.Loopback, 0, listen => listen.Protocols = HttpProtocols.Http2);
});
builder.Services.AddGrpc();
builder.Services.AddSingleton<FakeGraphEngineService>();
builder.Services.AddSingleton<FakeTimeEngineService>();
builder.Services.AddSingleton<FakeDocumentEngineService>();
builder.Services.AddSingleton<FakeRelationalEngineService>();
builder.Services.AddSingleton<FakeHealthService>();
WebApplication app = builder.Build();
app.MapGrpcService<FakeGraphEngineService>();
app.MapGrpcService<FakeTimeEngineService>();
app.MapGrpcService<FakeDocumentEngineService>();
app.MapGrpcService<FakeRelationalEngineService>();
app.MapGrpcService<FakeHealthService>();
await app.StartAsync();
int port = new Uri(app.Urls.Single()).Port;
FakeHealthService health = app.Services.GetRequiredService<FakeHealthService>();
return new NexusTestHost(app, port, health);
}
/// <summary>Builds a <c>nexus+&lt;scheme&gt;://127.0.0.1:{port}/{database}</c> connection URI for this host.</summary>
public string ConnectionUri(string scheme, string? database = null) =>
string.IsNullOrEmpty(database)
? $"{scheme}://127.0.0.1:{Port}"
: $"{scheme}://127.0.0.1:{Port}/{database}";
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
await _app.StopAsync();
await _app.DisposeAsync();
}
}

View File

@ -0,0 +1,286 @@
using Robotico.Nexus.Client.Tests.TestSupport;
using Robotico.Result.Errors;
using Robotico.Time;
using Xunit;
namespace Robotico.Nexus.Client.Tests.Time;
public class NexusTimeSessionTests : IAsyncLifetime
{
private static readonly IReadOnlyDictionary<string, string> NoLabels = new Dictionary<string, string>();
private NexusTestHost _host = null!;
private NexusClient _client = null!;
private ITimeSession _session = null!;
public async Task InitializeAsync()
{
_host = await NexusTestHost.StartAsync();
Robotico.Result.Result<NexusClient> connected = NexusClient.Connect(_host.ConnectionUri(NexusUriSchemes.Time));
Assert.True(connected.IsSuccess(out NexusClient? client, out _));
_client = client!;
Robotico.Result.Result<ITimeSession> opened = _client.Time.Open("session-time-db");
Assert.True(opened.IsSuccess(out ITimeSession? session, out _));
_session = session!;
}
public async Task DisposeAsync()
{
_session.Dispose();
_client.Dispose();
await _host.DisposeAsync();
}
[Fact]
public void Doctor_ReturnsSyntheticIdentity_AndCounts()
{
Robotico.Result.Result<TimeDoctorReport> result = _session.Doctor();
Assert.True(result.IsSuccess(out TimeDoctorReport? report, out _));
Assert.StartsWith("nexus://", report!.PrimaryPath, StringComparison.Ordinal);
Assert.Equal(report.PrimaryPath, report.WalPath);
Assert.Equal(0, report.MaxActiveSeries);
}
[Fact]
public void RegisterSeries_Succeeds_AndReturnsId()
{
Robotico.Result.Result<ulong> result = _session.RegisterSeries(new TimeSeriesKey("cpu.load", NoLabels));
Assert.True(result.IsSuccess(out ulong seriesId, out _));
Assert.True(seriesId > 0);
}
[Fact]
public void RegisterSeries_Fails_WhenMetricNameBlank()
{
Robotico.Result.Result<ulong> result = _session.RegisterSeries(new TimeSeriesKey(" ", NoLabels));
Assert.True(result.IsError(out IError? error));
Assert.Equal(TimeErrors.InvalidArgument, error!.Code);
}
[Fact]
public void AppendSample_ThenQueryRange_RoundTrips()
{
ulong seriesId = RegisterSeries("cpu.load");
Robotico.Result.Result appended = _session.AppendSample(seriesId, 1000, 42.5);
Assert.True(appended.IsSuccess());
Robotico.Result.Result<IReadOnlyList<TimeSample>> queried = _session.QueryRange(seriesId, 0, 2000, 10);
Assert.True(queried.IsSuccess(out IReadOnlyList<TimeSample>? samples, out _));
Assert.Single(samples!);
Assert.Equal(42.5, samples![0].Value);
}
[Fact]
public void AppendSample_Fails_WhenValueNotFinite()
{
ulong seriesId = RegisterSeries("cpu.load");
Robotico.Result.Result result = _session.AppendSample(seriesId, 1000, double.NaN);
Assert.True(result.IsError(out IError? error));
Assert.Equal(TimeErrors.InvalidArgument, error!.Code);
}
[Fact]
public void AppendSampleBatch_Succeeds()
{
ulong seriesId = RegisterSeries("mem.used");
TimeSample[] samples = [new TimeSample(1000, 1.0), new TimeSample(2000, 2.0)];
Robotico.Result.Result result = _session.AppendSampleBatch(seriesId, samples);
Assert.True(result.IsSuccess());
Robotico.Result.Result<IReadOnlyList<TimeSample>> queried = _session.QueryRange(seriesId, 0, 3000, 10);
Assert.True(queried.IsSuccess(out IReadOnlyList<TimeSample>? results, out _));
Assert.Equal(2, results!.Count);
}
[Fact]
public void AppendSampleBatch_Fails_WhenEmpty()
{
ulong seriesId = RegisterSeries("mem.used");
Robotico.Result.Result result = _session.AppendSampleBatch(seriesId, []);
Assert.True(result.IsError(out IError? error));
Assert.Equal(TimeErrors.InvalidArgument, error!.Code);
}
[Fact]
public void AppendSampleBatch_Fails_WhenAnyValueNotFinite()
{
ulong seriesId = RegisterSeries("mem.used");
Robotico.Result.Result result = _session.AppendSampleBatch(seriesId, [new TimeSample(1000, double.PositiveInfinity)]);
Assert.True(result.IsError(out IError? error));
Assert.Equal(TimeErrors.InvalidArgument, error!.Code);
}
[Fact]
public void Last_ReturnsNull_WhenNoSamples()
{
ulong seriesId = RegisterSeries("disk.free");
Robotico.Result.Result<TimeSample?> result = _session.Last(seriesId);
Assert.True(result.IsSuccess(out TimeSample? sample, out _));
Assert.Null(sample);
}
[Fact]
public void Last_ReturnsMostRecentSample()
{
ulong seriesId = RegisterSeries("disk.free");
_session.AppendSample(seriesId, 1000, 1.0);
_session.AppendSample(seriesId, 2000, 2.0);
Robotico.Result.Result<TimeSample?> result = _session.Last(seriesId);
Assert.True(result.IsSuccess(out TimeSample? sample, out _));
Assert.Equal(2.0, sample!.Value.Value);
}
[Fact]
public void RegisterEventTable_Succeeds()
{
TimeEventTableSchema schema = new("requests", [new TimeEventField("path", "Utf8", TimeEventFieldIndexKind.Inverted)]);
Robotico.Result.Result<ulong> result = _session.RegisterEventTable(schema);
Assert.True(result.IsSuccess(out ulong tableId, out _));
Assert.True(tableId > 0);
}
[Fact]
public void RegisterEventTable_Fails_WhenNoFields()
{
TimeEventTableSchema schema = new("requests", []);
Robotico.Result.Result<ulong> result = _session.RegisterEventTable(schema);
Assert.True(result.IsError(out IError? error));
Assert.Equal(TimeErrors.InvalidArgument, error!.Code);
}
[Fact]
public void RegisterEventTable_Fails_WhenTableNameBlank()
{
TimeEventTableSchema schema = new(string.Empty, [new TimeEventField("path", "Utf8", TimeEventFieldIndexKind.None)]);
Robotico.Result.Result<ulong> result = _session.RegisterEventTable(schema);
Assert.True(result.IsError(out IError? error));
Assert.Equal(TimeErrors.InvalidArgument, error!.Code);
}
[Fact]
public void AppendEvent_ThenQueryEvents_RoundTrips()
{
ulong tableId = RegisterEventTable();
Robotico.Result.Result<ulong> appended = _session.AppendEvent(tableId, 1000, [new TimeCell("Utf8", Utf8: "/api/foo")]);
Assert.True(appended.IsSuccess());
Robotico.Result.Result<IReadOnlyList<TimeWideEvent>> queried = _session.QueryEvents(tableId, 0, 2000, 10);
Assert.True(queried.IsSuccess(out IReadOnlyList<TimeWideEvent>? events, out _));
Assert.Single(events!);
Assert.Equal("/api/foo", events![0].Cells[0].Utf8);
}
[Fact]
public void AppendEvent_Fails_WhenCellsEmpty()
{
ulong tableId = RegisterEventTable();
Robotico.Result.Result<ulong> result = _session.AppendEvent(tableId, 1000, []);
Assert.True(result.IsError(out IError? error));
Assert.Equal(TimeErrors.InvalidArgument, error!.Code);
}
[Fact]
public void LookupInverted_FindsExactMatch()
{
ulong tableId = RegisterEventTable();
_session.AppendEvent(tableId, 1000, [new TimeCell("Utf8", Utf8: "/api/foo")]);
_session.AppendEvent(tableId, 2000, [new TimeCell("Utf8", Utf8: "/api/bar")]);
Robotico.Result.Result<IReadOnlyList<TimeWideEvent>> result = _session.LookupInverted(tableId, 0, "/api/foo", 10);
Assert.True(result.IsSuccess(out IReadOnlyList<TimeWideEvent>? events, out _));
Assert.Single(events!);
}
[Fact]
public void SearchFullText_FindsSubstringMatch()
{
ulong tableId = RegisterEventTable();
_session.AppendEvent(tableId, 1000, [new TimeCell("Utf8", Utf8: "hello world")]);
Robotico.Result.Result<IReadOnlyList<TimeSearchHit>> result = _session.SearchFullText(tableId, 0, "world", 10);
Assert.True(result.IsSuccess(out IReadOnlyList<TimeSearchHit>? hits, out _));
Assert.Single(hits!);
}
[Fact]
public void AllOperations_Fail_AfterDispose()
{
_session.Dispose();
Assert.True(_session.Doctor().IsError(out IError? e1));
Assert.Equal(NexusErrors.Disposed, e1!.Code);
Assert.True(_session.RegisterSeries(new TimeSeriesKey("x", NoLabels)).IsError(out IError? e2));
Assert.Equal(NexusErrors.Disposed, e2!.Code);
Assert.True(_session.AppendSample(1, 1, 1.0).IsError(out IError? e3));
Assert.Equal(NexusErrors.Disposed, e3!.Code);
Assert.True(_session.AppendSampleBatch(1, [new TimeSample(1, 1.0)]).IsError(out IError? e4));
Assert.Equal(NexusErrors.Disposed, e4!.Code);
Assert.True(_session.QueryRange(1, 0, 10, 10).IsError(out IError? e5));
Assert.Equal(NexusErrors.Disposed, e5!.Code);
Assert.True(_session.Last(1).IsError(out IError? e6));
Assert.Equal(NexusErrors.Disposed, e6!.Code);
Assert.True(_session.RegisterEventTable(new TimeEventTableSchema("t", [new TimeEventField("f", "Utf8", TimeEventFieldIndexKind.None)])).IsError(out IError? e7));
Assert.Equal(NexusErrors.Disposed, e7!.Code);
Assert.True(_session.AppendEvent(1, 1, [new TimeCell("Utf8", Utf8: "x")]).IsError(out IError? e8));
Assert.Equal(NexusErrors.Disposed, e8!.Code);
Assert.True(_session.QueryEvents(1, 0, 10, 10).IsError(out IError? e9));
Assert.Equal(NexusErrors.Disposed, e9!.Code);
Assert.True(_session.LookupInverted(1, 0, "x", 10).IsError(out IError? e10));
Assert.Equal(NexusErrors.Disposed, e10!.Code);
Assert.True(_session.SearchFullText(1, 0, "x", 10).IsError(out IError? e11));
Assert.Equal(NexusErrors.Disposed, e11!.Code);
}
private ulong RegisterSeries(string metricName)
{
Robotico.Result.Result<ulong> result = _session.RegisterSeries(new TimeSeriesKey(metricName, NoLabels));
Assert.True(result.IsSuccess(out ulong id, out _));
return id;
}
private ulong RegisterEventTable()
{
TimeEventTableSchema schema = new("events-" + Guid.NewGuid(), [new TimeEventField("body", "Utf8", TimeEventFieldIndexKind.FullText)]);
Robotico.Result.Result<ulong> result = _session.RegisterEventTable(schema);
Assert.True(result.IsSuccess(out ulong id, out _));
return id;
}
}

View File

@ -0,0 +1,89 @@
using Robotico.Nexus.Client.Tests.TestSupport;
using Robotico.Result.Errors;
using Robotico.Time;
using Xunit;
namespace Robotico.Nexus.Client.Tests.Time;
public class NexusTimeStoreTests : IAsyncLifetime
{
private NexusTestHost _host = null!;
private NexusClient _client = null!;
public async Task InitializeAsync()
{
_host = await NexusTestHost.StartAsync();
Robotico.Result.Result<NexusClient> connected = NexusClient.Connect(_host.ConnectionUri(NexusUriSchemes.Time));
Assert.True(connected.IsSuccess(out NexusClient? client, out _));
_client = client!;
}
public async Task DisposeAsync()
{
_client.Dispose();
await _host.DisposeAsync();
}
[Fact]
public void Open_Succeeds_WhenEngineReachable()
{
Robotico.Result.Result<ITimeSession> result = _client.Time.Open("mytimedb");
Assert.True(result.IsSuccess(out ITimeSession? session, out _));
session!.Dispose();
}
[Fact]
public void Open_UsesDefaultDatabaseName_WhenPathBlank()
{
Robotico.Result.Result<ITimeSession> result = _client.Time.Open(string.Empty);
Assert.True(result.IsSuccess(out ITimeSession? session, out _));
session!.Dispose();
}
[Fact]
public void Open_Fails_WhenPingReportsEngineFailure()
{
Robotico.Result.Result<ITimeSession> result = _client.Time.Open(FakeNexusTriggers.PingReportsFailure);
Assert.True(result.IsError(out IError? error));
Assert.Equal(NexusErrors.Unavailable, error!.Code);
}
[Fact]
public void Open_Fails_WhenPingThrowsRpcException()
{
Robotico.Result.Result<ITimeSession> result = _client.Time.Open(FakeNexusTriggers.PingThrows);
Assert.True(result.IsError(out IError? error));
Assert.Equal(NexusErrors.Unavailable, error!.Code);
}
[Fact]
public void OpenOrCreate_DelegatesTo_Open()
{
Robotico.Result.Result<ITimeSession> result = _client.Time.OpenOrCreate("greenfield-time-db");
Assert.True(result.IsSuccess(out ITimeSession? session, out _));
session!.Dispose();
}
[Fact]
public void Create_Fails_WithAlreadyExists_WhenPingSucceeds()
{
Robotico.Result.Result<ITimeSession> result = _client.Time.Create("already-reachable-time-db");
Assert.True(result.IsError(out IError? error));
Assert.Equal(NexusErrors.AlreadyExists, error!.Code);
}
[Fact]
public void Create_Fails_WhenInitialPingReportsFailure()
{
Robotico.Result.Result<ITimeSession> result = _client.Time.Create(FakeNexusTriggers.PingReportsFailure);
Assert.True(result.IsError(out IError? error));
Assert.Equal(NexusErrors.Unavailable, error!.Code);
}
}

View File

@ -0,0 +1,101 @@
using Mishima.Nexus.Wire.V1;
using Robotico.Nexus.Client.Time;
using Robotico.Time;
using Xunit;
namespace Robotico.Nexus.Client.Tests.Time;
public class TimeWireMappingTests
{
[Fact]
public void ToWire_ThenFromWire_RoundTrips_Int64()
{
TimeCell cell = new("Int64", I64: 42);
TimeCellWire wire = TimeWireMapping.ToWire(cell);
TimeCell roundTripped = TimeWireMapping.FromWire(wire);
Assert.Equal(42, wire.I64);
Assert.Equal(cell, roundTripped);
}
[Fact]
public void ToWire_ThenFromWire_RoundTrips_UInt64()
{
TimeCell cell = new("UInt64", U64: 7);
TimeCellWire wire = TimeWireMapping.ToWire(cell);
Assert.Equal(7ul, wire.U64);
Assert.Equal(cell, TimeWireMapping.FromWire(wire));
}
[Fact]
public void ToWire_ThenFromWire_RoundTrips_Double()
{
TimeCell cell = new("Double", F64: 3.5);
TimeCellWire wire = TimeWireMapping.ToWire(cell);
Assert.Equal(3.5, wire.F64);
Assert.Equal(cell, TimeWireMapping.FromWire(wire));
}
[Fact]
public void ToWire_ThenFromWire_RoundTrips_Boolean()
{
TimeCell cell = new("Boolean", Boolean: true);
TimeCellWire wire = TimeWireMapping.ToWire(cell);
Assert.True(wire.Boolean);
Assert.Equal(cell, TimeWireMapping.FromWire(wire));
}
[Fact]
public void ToWire_ThenFromWire_RoundTrips_Utf8Default()
{
TimeCell cell = new("Utf8", Utf8: "hello");
TimeCellWire wire = TimeWireMapping.ToWire(cell);
Assert.Equal("hello", wire.Utf8);
Assert.Equal(cell, TimeWireMapping.FromWire(wire));
}
[Fact]
public void ToWire_Null_DoesNotPopulateAnyValue()
{
TimeCell cell = new("Int64", IsNull: true);
TimeCellWire wire = TimeWireMapping.ToWire(cell);
Assert.True(wire.IsNull);
Assert.Equal(0, wire.I64);
}
[Fact]
public void FromWire_Null_ReturnsNullCell()
{
TimeCellWire wire = new() { WireTypeName = "Int64", IsNull = true };
TimeCell cell = TimeWireMapping.FromWire(wire);
Assert.True(cell.IsNull);
}
[Theory]
[InlineData(Robotico.Time.TimeEventFieldIndexKind.None, Mishima.Nexus.Wire.V1.TimeEventFieldIndexKind.None)]
[InlineData(Robotico.Time.TimeEventFieldIndexKind.Inverted, Mishima.Nexus.Wire.V1.TimeEventFieldIndexKind.Inverted)]
[InlineData(Robotico.Time.TimeEventFieldIndexKind.FullText, Mishima.Nexus.Wire.V1.TimeEventFieldIndexKind.FullText)]
public void ToWire_MapsFieldIndexKind(Robotico.Time.TimeEventFieldIndexKind kind, Mishima.Nexus.Wire.V1.TimeEventFieldIndexKind expected)
{
Assert.Equal(expected, TimeWireMapping.ToWire(kind));
}
[Fact]
public void ToWire_UnknownFieldIndexKind_DefaultsToNone()
{
Assert.Equal(Mishima.Nexus.Wire.V1.TimeEventFieldIndexKind.None, TimeWireMapping.ToWire((Robotico.Time.TimeEventFieldIndexKind)99));
}
}

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<RunSettings>
<DataCollectionRunSettings>
<DataCollectors>
<DataCollector friendlyName="XPlat Code Coverage">
<Configuration>
<Format>cobertura</Format>
<Include>[Robotico.Nexus.Client]*</Include>
<Exclude>[Robotico.Result]*,[Robotico.Graph]*,[Robotico.Time]*,[Mishima.Nexus.Wire.V1]*,[xunit.*]*,[coverlet.*]*</Exclude>
</Configuration>
</DataCollector>
</DataCollectors>
</DataCollectionRunSettings>
</RunSettings>