feat: initial Robotico.Nexus.Client (NexusClient, Graph/Time ports, Document/Relational helpers)
ci/woodpecker/push/woodpecker Pipeline failed
Details
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:
commit
cbae4d1aa2
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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/
|
||||
|
|
@ -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
|
||||
|
|
@ -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`).
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
@ -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.
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"sdk": {
|
||||
"version": "10.0.5",
|
||||
"rollForward": "latestFeature"
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}"
|
||||
|
|
@ -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}"
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1 @@
|
|||
src/Robotico.Nexus.Client/Robotico.Nexus.Client.csproj
|
||||
|
|
@ -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
|
||||
|
|
@ -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}"
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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!;
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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" });
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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+<scheme>://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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
Loading…
Reference in New Issue