From cbae4d1aa2fec9b8bcae858be3987439d71998e7 Mon Sep 17 00:00:00 2001 From: Sergej Kern Date: Sat, 25 Jul 2026 03:44:53 +0200 Subject: [PATCH] feat: initial Robotico.Nexus.Client (NexusClient, Graph/Time ports, Document/Relational helpers) 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 --- .editorconfig | 148 +++++++ .github/dependabot.yml | 11 + .github/workflows/ci.yml | 35 ++ .github/workflows/publish.yml | 68 ++++ .gitignore | 62 +++ .woodpecker.yml | 35 ++ CHANGELOG.md | 24 ++ Directory.Build.props | 44 +++ Directory.Packages.props | 19 + LICENSE | 21 + README.md | 114 ++++++ Robotico.Library.AnalyzerDefaults.props | 43 +++ Robotico.Library.MeziantouNoWarn.props | 6 + Robotico.Library.Shared.props | 39 ++ docs/adr/0001-nexus-client-monolith.adoc | 68 ++++ global.json | 6 + nuget.config | 17 + robotico-nexus-client-csharp.slnx | 9 + scripts/ci-build-pack-projects.sh | 18 + scripts/ensure-git-tag.sh | 74 ++++ scripts/nuget-push-download.sh | 69 ++++ scripts/pack-projects.list | 1 + scripts/pack-release.sh | 39 ++ scripts/read-package-version.sh | 42 ++ scripts/should-skip-release.sh | 62 +++ scripts/woodpecker-release.sh | 21 + scripts/woodpecker-verify.sh | 43 +++ .../Document/NexusDocumentClient.cs | 89 +++++ .../Graph/NexusGraphSession.cs | 310 +++++++++++++++ .../Graph/NexusGraphStore.cs | 147 +++++++ .../NexusCallMetadata.cs | 25 ++ src/Robotico.Nexus.Client/NexusClient.cs | 185 +++++++++ .../NexusClientOptions.cs | 49 +++ .../NexusConnectionUri.cs | 80 ++++ .../NexusConnectionUriParts.cs | 8 + src/Robotico.Nexus.Client/NexusErrors.cs | 46 +++ .../NexusGrpcChannelFactory.cs | 69 ++++ src/Robotico.Nexus.Client/NexusTlsMaterial.cs | 78 ++++ src/Robotico.Nexus.Client/NexusUriSchemes.cs | 20 + src/Robotico.Nexus.Client/NexusWireStatus.cs | 68 ++++ .../Relational/NexusRelationalClient.cs | 41 ++ .../Robotico.Nexus.Client.csproj | 42 ++ .../Time/NexusTimeSession.cs | 364 ++++++++++++++++++ .../Time/NexusTimeStore.cs | 111 ++++++ .../Time/TimeWireMapping.cs | 66 ++++ .../Document/NexusDocumentClientTests.cs | 96 +++++ .../Graph/NexusGraphSessionTests.cs | 250 ++++++++++++ .../Graph/NexusGraphStoreTests.cs | 102 +++++ .../NexusCallMetadataTests.cs | 40 ++ .../NexusClientOptionsTests.cs | 103 +++++ .../NexusClientTests.cs | 224 +++++++++++ .../NexusConnectionUriTests.cs | 109 ++++++ .../NexusErrorsTests.cs | 24 ++ .../NexusGrpcChannelFactoryTests.cs | 47 +++ .../NexusTlsMaterialTests.cs | 113 ++++++ .../NexusWireStatusTests.cs | 115 ++++++ .../Relational/NexusRelationalClientTests.cs | 70 ++++ .../Robotico.Nexus.Client.Tests.csproj | 30 ++ .../TestSupport/FakeDocumentEngineService.cs | 45 +++ .../TestSupport/FakeGraphEngineService.cs | 137 +++++++ .../TestSupport/FakeHealthService.cs | 19 + .../TestSupport/FakeNexusTriggers.cs | 32 ++ .../FakeRelationalEngineService.cs | 18 + .../TestSupport/FakeTimeEngineService.cs | 166 ++++++++ .../TestSupport/NexusStatuses.cs | 16 + .../TestSupport/NexusTestHost.cs | 72 ++++ .../Time/NexusTimeSessionTests.cs | 286 ++++++++++++++ .../Time/NexusTimeStoreTests.cs | 89 +++++ .../Time/TimeWireMappingTests.cs | 101 +++++ tests/coverlet.runsettings | 14 + 70 files changed, 5254 insertions(+) create mode 100644 .editorconfig create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/publish.yml create mode 100644 .gitignore create mode 100644 .woodpecker.yml create mode 100644 CHANGELOG.md create mode 100644 Directory.Build.props create mode 100644 Directory.Packages.props create mode 100644 LICENSE create mode 100644 README.md create mode 100644 Robotico.Library.AnalyzerDefaults.props create mode 100644 Robotico.Library.MeziantouNoWarn.props create mode 100644 Robotico.Library.Shared.props create mode 100644 docs/adr/0001-nexus-client-monolith.adoc create mode 100644 global.json create mode 100644 nuget.config create mode 100644 robotico-nexus-client-csharp.slnx create mode 100755 scripts/ci-build-pack-projects.sh create mode 100755 scripts/ensure-git-tag.sh create mode 100755 scripts/nuget-push-download.sh create mode 100644 scripts/pack-projects.list create mode 100755 scripts/pack-release.sh create mode 100755 scripts/read-package-version.sh create mode 100755 scripts/should-skip-release.sh create mode 100755 scripts/woodpecker-release.sh create mode 100755 scripts/woodpecker-verify.sh create mode 100644 src/Robotico.Nexus.Client/Document/NexusDocumentClient.cs create mode 100644 src/Robotico.Nexus.Client/Graph/NexusGraphSession.cs create mode 100644 src/Robotico.Nexus.Client/Graph/NexusGraphStore.cs create mode 100644 src/Robotico.Nexus.Client/NexusCallMetadata.cs create mode 100644 src/Robotico.Nexus.Client/NexusClient.cs create mode 100644 src/Robotico.Nexus.Client/NexusClientOptions.cs create mode 100644 src/Robotico.Nexus.Client/NexusConnectionUri.cs create mode 100644 src/Robotico.Nexus.Client/NexusConnectionUriParts.cs create mode 100644 src/Robotico.Nexus.Client/NexusErrors.cs create mode 100644 src/Robotico.Nexus.Client/NexusGrpcChannelFactory.cs create mode 100644 src/Robotico.Nexus.Client/NexusTlsMaterial.cs create mode 100644 src/Robotico.Nexus.Client/NexusUriSchemes.cs create mode 100644 src/Robotico.Nexus.Client/NexusWireStatus.cs create mode 100644 src/Robotico.Nexus.Client/Relational/NexusRelationalClient.cs create mode 100644 src/Robotico.Nexus.Client/Robotico.Nexus.Client.csproj create mode 100644 src/Robotico.Nexus.Client/Time/NexusTimeSession.cs create mode 100644 src/Robotico.Nexus.Client/Time/NexusTimeStore.cs create mode 100644 src/Robotico.Nexus.Client/Time/TimeWireMapping.cs create mode 100644 tests/Robotico.Nexus.Client.Tests/Document/NexusDocumentClientTests.cs create mode 100644 tests/Robotico.Nexus.Client.Tests/Graph/NexusGraphSessionTests.cs create mode 100644 tests/Robotico.Nexus.Client.Tests/Graph/NexusGraphStoreTests.cs create mode 100644 tests/Robotico.Nexus.Client.Tests/NexusCallMetadataTests.cs create mode 100644 tests/Robotico.Nexus.Client.Tests/NexusClientOptionsTests.cs create mode 100644 tests/Robotico.Nexus.Client.Tests/NexusClientTests.cs create mode 100644 tests/Robotico.Nexus.Client.Tests/NexusConnectionUriTests.cs create mode 100644 tests/Robotico.Nexus.Client.Tests/NexusErrorsTests.cs create mode 100644 tests/Robotico.Nexus.Client.Tests/NexusGrpcChannelFactoryTests.cs create mode 100644 tests/Robotico.Nexus.Client.Tests/NexusTlsMaterialTests.cs create mode 100644 tests/Robotico.Nexus.Client.Tests/NexusWireStatusTests.cs create mode 100644 tests/Robotico.Nexus.Client.Tests/Relational/NexusRelationalClientTests.cs create mode 100644 tests/Robotico.Nexus.Client.Tests/Robotico.Nexus.Client.Tests.csproj create mode 100644 tests/Robotico.Nexus.Client.Tests/TestSupport/FakeDocumentEngineService.cs create mode 100644 tests/Robotico.Nexus.Client.Tests/TestSupport/FakeGraphEngineService.cs create mode 100644 tests/Robotico.Nexus.Client.Tests/TestSupport/FakeHealthService.cs create mode 100644 tests/Robotico.Nexus.Client.Tests/TestSupport/FakeNexusTriggers.cs create mode 100644 tests/Robotico.Nexus.Client.Tests/TestSupport/FakeRelationalEngineService.cs create mode 100644 tests/Robotico.Nexus.Client.Tests/TestSupport/FakeTimeEngineService.cs create mode 100644 tests/Robotico.Nexus.Client.Tests/TestSupport/NexusStatuses.cs create mode 100644 tests/Robotico.Nexus.Client.Tests/TestSupport/NexusTestHost.cs create mode 100644 tests/Robotico.Nexus.Client.Tests/Time/NexusTimeSessionTests.cs create mode 100644 tests/Robotico.Nexus.Client.Tests/Time/NexusTimeStoreTests.cs create mode 100644 tests/Robotico.Nexus.Client.Tests/Time/TimeWireMappingTests.cs create mode 100644 tests/coverlet.runsettings diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..7aad57e --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..f63a305 --- /dev/null +++ b/.github/dependabot.yml @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5020cac --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..39c1819 --- /dev/null +++ b/.github/workflows/publish.yml @@ -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 '' Directory.Build.props | head -1 | sed 's/.*\([^<]*\)<\/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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1d83112 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/.woodpecker.yml b/.woodpecker.yml new file mode 100644 index 0000000..f9e1646 --- /dev/null +++ b/.woodpecker.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c1a4020 --- /dev/null +++ b/CHANGELOG.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`). diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..d7e3993 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,44 @@ + + + <_RoboticoSharedMsbuildImport Condition="Exists('$(MSBuildThisFileDirectory)Robotico.Library.Shared.props')">$(MSBuildThisFileDirectory)Robotico.Library.Shared.props + <_RoboticoSharedMsbuildImport Condition="'$(_RoboticoSharedMsbuildImport)' == ''">$([MSBuild]::GetPathOfFileAbove('Robotico.Library.Shared.props', '$(MSBuildThisFileDirectory)')) + + + + + + net10.0 + + + + 1.0.0 + Robotico.Nexus.Client + Robotico.Nexus.Client + Robotico.Nexus.Client + 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. + nexus;mishima;grpc;robotico;client;graph;time + robotico.dev Kft. + robotico.dev Kft. + Copyright © 2026 robotico.dev Kft. + https://download.robotico.dev + enable + enable + true + true + true + + + + <_RoboticoCsharpNuGetsDir>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\nugets')) + + + $([MSBuild]::EnsureTrailingSlash('$(_RoboticoCsharpNuGetsDir)')) + + + + + $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\robotico-graph-csharp\src\Robotico.Graph\Robotico.Graph.csproj')) + $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\robotico-time-csharp\src\Robotico.Time\Robotico.Time.csproj')) + $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\..\mishima-suite\mishima-nexus\src\Mishima.Nexus.Wire.V1\Mishima.Nexus.Wire.V1.csproj')) + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..bd40ceb --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,19 @@ + + + true + + + + + + + + + + + + + + + + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f116468 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4cc4f86 --- /dev/null +++ b/README.md @@ -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 connected = NexusClient.Connect("nexus+graph://localhost:50051/pathways"); +using NexusClient client = connected.ExpectSuccess(); + +client.CheckHealth().ExpectSuccess(); + +Result 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 +``` diff --git a/Robotico.Library.AnalyzerDefaults.props b/Robotico.Library.AnalyzerDefaults.props new file mode 100644 index 0000000..54d0942 --- /dev/null +++ b/Robotico.Library.AnalyzerDefaults.props @@ -0,0 +1,43 @@ + + + + enable + enable + true + 8 + latest-all + true + true + Robotico + Robotico + Copyright © Robotico + git + true + All + README.md + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + diff --git a/Robotico.Library.MeziantouNoWarn.props b/Robotico.Library.MeziantouNoWarn.props new file mode 100644 index 0000000..76fa595 --- /dev/null +++ b/Robotico.Library.MeziantouNoWarn.props @@ -0,0 +1,6 @@ + + + + $(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 + + diff --git a/Robotico.Library.Shared.props b/Robotico.Library.Shared.props new file mode 100644 index 0000000..2b05ee8 --- /dev/null +++ b/Robotico.Library.Shared.props @@ -0,0 +1,39 @@ + + + + + + + + + + + net8.0;net10.0 + + latest + + + + + + <_RoboticoLocalNugetConfig>$([MSBuild]::GetPathOfFileAbove('nuget.Local.config', '$(MSBuildThisFileDirectory)')) + + + $(_RoboticoLocalNugetConfig) + + + + diff --git a/docs/adr/0001-nexus-client-monolith.adoc b/docs/adr/0001-nexus-client-monolith.adoc new file mode 100644 index 0000000..0f98cd8 --- /dev/null +++ b/docs/adr/0001-nexus-client-monolith.adoc @@ -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. diff --git a/global.json b/global.json new file mode 100644 index 0000000..d91d9ff --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "10.0.5", + "rollForward": "latestFeature" + } +} diff --git a/nuget.config b/nuget.config new file mode 100644 index 0000000..35941ab --- /dev/null +++ b/nuget.config @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/robotico-nexus-client-csharp.slnx b/robotico-nexus-client-csharp.slnx new file mode 100644 index 0000000..312eed9 --- /dev/null +++ b/robotico-nexus-client-csharp.slnx @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/scripts/ci-build-pack-projects.sh b/scripts/ci-build-pack-projects.sh new file mode 100755 index 0000000..f87ab2a --- /dev/null +++ b/scripts/ci-build-pack-projects.sh @@ -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}" diff --git a/scripts/ensure-git-tag.sh b/scripts/ensure-git-tag.sh new file mode 100755 index 0000000..cf63b39 --- /dev/null +++ b/scripts/ensure-git-tag.sh @@ -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 }" +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 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 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}" diff --git a/scripts/nuget-push-download.sh b/scripts/nuget-push-download.sh new file mode 100755 index 0000000..f25d46f --- /dev/null +++ b/scripts/nuget-push-download.sh @@ -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 diff --git a/scripts/pack-projects.list b/scripts/pack-projects.list new file mode 100644 index 0000000..02839b0 --- /dev/null +++ b/scripts/pack-projects.list @@ -0,0 +1 @@ +src/Robotico.Nexus.Client/Robotico.Nexus.Client.csproj diff --git a/scripts/pack-release.sh b/scripts/pack-release.sh new file mode 100755 index 0000000..fc0b1a9 --- /dev/null +++ b/scripts/pack-release.sh @@ -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 diff --git a/scripts/read-package-version.sh b/scripts/read-package-version.sh new file mode 100755 index 0000000..1c95975 --- /dev/null +++ b/scripts/read-package-version.sh @@ -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 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}" diff --git a/scripts/should-skip-release.sh b/scripts/should-skip-release.sh new file mode 100755 index 0000000..09607d6 --- /dev/null +++ b/scripts/should-skip-release.sh @@ -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 '' 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 diff --git a/scripts/woodpecker-release.sh b/scripts/woodpecker-release.sh new file mode 100755 index 0000000..c247862 --- /dev/null +++ b/scripts/woodpecker-release.sh @@ -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 diff --git a/scripts/woodpecker-verify.sh b/scripts/woodpecker-verify.sh new file mode 100755 index 0000000..33e7624 --- /dev/null +++ b/scripts/woodpecker-verify.sh @@ -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" diff --git a/src/Robotico.Nexus.Client/Document/NexusDocumentClient.cs b/src/Robotico.Nexus.Client/Document/NexusDocumentClient.cs new file mode 100644 index 0000000..8d0a969 --- /dev/null +++ b/src/Robotico.Nexus.Client/Document/NexusDocumentClient.cs @@ -0,0 +1,89 @@ +using Grpc.Core; +using Grpc.Net.Client; +using Mishima.Nexus.Wire.V1; + +namespace Robotico.Nexus.Client.Document; + +/// +/// Thin document-plane façade: liveness () plus single-document +/// upsert/get/delete — enough for MVP smoke usage. This is not a full IMishimaDatabase +/// façade (no collections, indexes, batches, or change feed); use the generated +/// +/// directly (via ) for the full RPC surface. +/// +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; + } + + /// Calls NexusDocumentEngineService.Ping. + public Robotico.Result.Result CheckHealth() => Invoke(() => + { + DocumentPingResponse response = _client.Ping(new DocumentPingRequest(), Metadata()); + return NexusWireStatus.ToResult(response.Status); + }); + + /// Inserts or replaces a document by id within a collection. + 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); + }); + + /// Reads a document's JSON body, or when not found. + public Robotico.Result.Result 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); + }); + + /// Deletes a document by id when present (idempotent). + 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 call) + { + try + { + return call(); + } + catch (RpcException ex) + { + return Robotico.Result.Result.Error(NexusWireStatus.FromException(ex)); + } + } + + private static Robotico.Result.Result InvokeGeneric(Func> call) + { + try + { + return call(); + } + catch (RpcException ex) + { + return Robotico.Result.Result.Error(NexusWireStatus.FromException(ex)); + } + } +} diff --git a/src/Robotico.Nexus.Client/Graph/NexusGraphSession.cs b/src/Robotico.Nexus.Client/Graph/NexusGraphSession.cs new file mode 100644 index 0000000..ae33b1e --- /dev/null +++ b/src/Robotico.Nexus.Client/Graph/NexusGraphSession.cs @@ -0,0 +1,310 @@ +using Grpc.Core; +using Mishima.Nexus.Wire.V1; +using Robotico.Graph; +using Robotico.Result.Errors; + +namespace Robotico.Nexus.Client.Graph; + +/// +/// bound to one Nexus logical graph database. Does not expose +/// Cypher/Gremlin/SPARQL/Vacuum — those stay on Nexus language packs, out of this port. +/// +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; + } + + /// + 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 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)); + } + + /// + 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 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)); + } + + /// + public Robotico.Result.Result UpsertVertex(ulong vertexId, IReadOnlyList labelNames) + { + Robotico.Result.Result disposed = EnsureNotDisposed(); + if (disposed.IsError(out IError? disposedError)) + { + return Robotico.Result.Result.Error(disposedError!); + } + + if (labelNames is null || labelNames.Count is 0) + { + return Robotico.Result.Result.Error(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); + }); + } + + /// + public Robotico.Result.Result DeleteVertex(ulong vertexId) + { + Robotico.Result.Result disposed = EnsureNotDisposed(); + if (disposed.IsError(out IError? disposedError)) + { + return Robotico.Result.Result.Error(disposedError!); + } + + return InvokeGeneric(() => + { + GraphDeleteVertexResponse response = _client.DeleteVertex(new GraphDeleteVertexRequest { VertexId = vertexId }, Metadata()); + return NexusWireStatus.ToResult(response.Status, response.CommittedTxnId); + }); + } + + /// + public Robotico.Result.Result GetVertex(ulong vertexId) + { + Robotico.Result.Result disposed = EnsureNotDisposed(); + if (disposed.IsError(out IError? disposedError)) + { + return Robotico.Result.Result.Error(disposedError!); + } + + return InvokeGeneric(() => + { + GraphGetVertexResponse response = _client.GetVertex(new GraphGetVertexRequest { VertexId = vertexId }, Metadata()); + return NexusWireStatus.ToResult(response.Status, new GraphVertex(response.VertexId, [.. response.LabelNames])); + }); + } + + /// + public Robotico.Result.Result UpsertEdge(ulong sourceVertexId, ulong targetVertexId, string edgeTypeName) + { + Robotico.Result.Result disposed = EnsureNotDisposed(); + if (disposed.IsError(out IError? disposedError)) + { + return Robotico.Result.Result.Error(disposedError!); + } + + Robotico.Result.Result name = RequireName(edgeTypeName, "edge type"); + if (!name.IsSuccess(out string? trimmed, out IError? nameError)) + { + return Robotico.Result.Result.Error(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); + }); + } + + /// + public Robotico.Result.Result DeleteEdge(ulong sourceVertexId, ulong targetVertexId, string edgeTypeName) + { + Robotico.Result.Result disposed = EnsureNotDisposed(); + if (disposed.IsError(out IError? disposedError)) + { + return Robotico.Result.Result.Error(disposedError!); + } + + Robotico.Result.Result name = RequireName(edgeTypeName, "edge type"); + if (!name.IsSuccess(out string? trimmed, out IError? nameError)) + { + return Robotico.Result.Result.Error(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); + }); + } + + /// + public Robotico.Result.Result GetEdge(ulong sourceVertexId, ulong targetVertexId, string edgeTypeName) + { + Robotico.Result.Result disposed = EnsureNotDisposed(); + if (disposed.IsError(out IError? disposedError)) + { + return Robotico.Result.Result.Error(disposedError!); + } + + Robotico.Result.Result name = RequireName(edgeTypeName, "edge type"); + if (!name.IsSuccess(out string? trimmed, out IError? nameError)) + { + return Robotico.Result.Result.Error(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)); + }); + } + + /// + public Robotico.Result.Result 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(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 distances = new(response.DistanceByVertex.Count); + foreach (KeyValuePair pair in response.DistanceByVertex) + { + distances[pair.Key] = pair.Value; + } + + return NexusWireStatus.ToResult( + response.Status, + new GraphNeighborhood(startVertexId, [.. response.VisitOrder], distances)); + }); + } + + /// + public Robotico.Result.Result 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(disposedError!); + } + + Robotico.Result.Result name = RequireName(labelName, "label"); + if (!name.IsSuccess(out string? trimmed, out IError? nameError)) + { + return Robotico.Result.Result.Error(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)); + }); + } + + /// + 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 RequireName(string value, string kind) + { + if (string.IsNullOrWhiteSpace(value)) + { + return Robotico.Result.Result.Error(GraphErrors.Error($"{kind} name is required.", GraphErrors.InvalidArgument)); + } + + return Robotico.Result.Result.Success(value.Trim()); + } + + private static Robotico.Result.Result Invoke(Func call) + { + try + { + return call(); + } + catch (RpcException ex) + { + return Robotico.Result.Result.Error(NexusWireStatus.FromException(ex)); + } + } + + private static Robotico.Result.Result InvokeGeneric(Func> call) + { + try + { + return call(); + } + catch (RpcException ex) + { + return Robotico.Result.Result.Error(NexusWireStatus.FromException(ex)); + } + } +} diff --git a/src/Robotico.Nexus.Client/Graph/NexusGraphStore.cs b/src/Robotico.Nexus.Client/Graph/NexusGraphStore.cs new file mode 100644 index 0000000..77dfbab --- /dev/null +++ b/src/Robotico.Nexus.Client/Graph/NexusGraphStore.cs @@ -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; + +/// +/// over the Nexus graph plane gRPC service. Per POLA, the +/// databaseFilePath argument on every method is the logical database name (Nexus +/// x-nexus-database metadata value), not a filesystem path — see +/// docs/adr/0001-nexus-client-monolith.adoc. +/// +/// +/// Ping is an engine-liveness probe on the Nexus graph host, not a per-database +/// existence check, so will typically report +/// once the engine is reachable. Use for greenfield databases — the Nexus +/// host provisions the logical database lazily on the first RPC that touches its state. +/// +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; + } + + /// + public Robotico.Result.Result Create( + string databaseFilePath, + IReadOnlyList? initialLabels = null, + IReadOnlyList? initialEdgeTypes = null) + { + Robotico.Result.Result nameResult = ResolveName(databaseFilePath); + if (!nameResult.IsSuccess(out string? name, out IError? nameError)) + { + return Robotico.Result.Result.Error(nameError!); + } + + Robotico.Result.Result reachable = PingReachable(name!); + if (!reachable.IsSuccess(out bool alreadyUp, out IError? pingError)) + { + return Robotico.Result.Result.Error(pingError!); + } + + if (alreadyUp) + { + return Robotico.Result.Result.Error(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 opened = OpenCore(name!); + if (!opened.IsSuccess(out IGraphSession? session, out IError? openError)) + { + return Robotico.Result.Result.Error(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(labelError!); + } + } + + foreach (string edgeType in initialEdgeTypes ?? []) + { + Robotico.Result.Result registered = session.RegisterEdgeType(edgeType); + if (registered.IsError(out IError? edgeError)) + { + return Robotico.Result.Result.Error(edgeError!); + } + } + + IGraphSession created = session; + session = null; + return Robotico.Result.Result.Success(created); + } + finally + { + session?.Dispose(); + } + } + + /// + public Robotico.Result.Result Open(string databaseFilePath) + { + Robotico.Result.Result nameResult = ResolveName(databaseFilePath); + return nameResult.IsSuccess(out string? name, out IError? nameError) + ? OpenCore(name!) + : Robotico.Result.Result.Error(nameError!); + } + + /// + public Robotico.Result.Result 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.Success; the caller is responsible for disposing it.")] + private Robotico.Result.Result OpenCore(string name) + { + Robotico.Result.Result reachable = PingReachable(name); + if (!reachable.IsSuccess(out bool ok, out IError? pingError)) + { + return Robotico.Result.Result.Error(pingError!); + } + + return ok + ? Robotico.Result.Result.Success(new NexusGraphSession(_client, name, _bearerToken)) + : Robotico.Result.Result.Error(NexusErrors.Error($"Graph engine ping failed for database '{name}'.", NexusErrors.Unavailable)); + } + + private Robotico.Result.Result 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(NexusWireStatus.FromException(ex)); + } + } + + private Robotico.Result.Result ResolveName(string databaseFilePath) + { + if (string.IsNullOrWhiteSpace(databaseFilePath)) + { + return string.IsNullOrWhiteSpace(_defaultDatabaseName) + ? Robotico.Result.Result.Error(NexusErrors.Error("Logical database name is required.", NexusErrors.InvalidArgument)) + : Robotico.Result.Result.Success(_defaultDatabaseName); + } + + return Robotico.Result.Result.Success(databaseFilePath.Trim()); + } +} diff --git a/src/Robotico.Nexus.Client/NexusCallMetadata.cs b/src/Robotico.Nexus.Client/NexusCallMetadata.cs new file mode 100644 index 0000000..0e98402 --- /dev/null +++ b/src/Robotico.Nexus.Client/NexusCallMetadata.cs @@ -0,0 +1,25 @@ +using Grpc.Core; + +namespace Robotico.Nexus.Client; + +/// Builds per-call gRPC metadata (logical database + optional bearer) for wire calls. +internal static class NexusCallMetadata +{ + /// gRPC metadata key for logical database selection (matches Nexus server-side convention). + internal const string DatabaseKey = "x-nexus-database"; + + /// Builds headers for a single RPC bound to . + 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; + } +} diff --git a/src/Robotico.Nexus.Client/NexusClient.cs b/src/Robotico.Nexus.Client/NexusClient.cs new file mode 100644 index 0000000..023f185 --- /dev/null +++ b/src/Robotico.Nexus.Client/NexusClient.cs @@ -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; + +/// +/// Connects to a Nexus wire endpoint and exposes plane facades: / +/// implement Robotico.Graph / Robotico.Time ports over gRPC, and +/// / return thin document/relational +/// façades (MVP — no full IMishimaDatabase façade; use / +/// 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 to talk to another plane or database. +/// +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); + } + + /// Connection scheme () resolved at connect time. + public string Scheme { get; } + + /// Logical database bound at connect time (URI path segment, or the scheme's canonical default). + public string Database { get; } + + /// + /// Graph plane store bound to when is + /// ; usable against any logical database name via + /// regardless of scheme. + /// + public IGraphStore Graph { get; } + + /// + /// Time plane store bound to when is + /// ; usable against any logical database name via + /// regardless of scheme. + /// + public ITimeStore Time { get; } + + /// Connects to a Nexus wire endpoint. Validates the URI and opens a gRPC channel eagerly. + [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.Success; the caller is responsible for disposing it.")] + public static Robotico.Result.Result Connect(string connectionUri, NexusClientOptions? options = null) + { + Robotico.Result.Result parsedResult = NexusConnectionUri.TryParse(connectionUri); + if (!parsedResult.IsSuccess(out NexusConnectionUriParts? parsed, out IError? parseError)) + { + return Robotico.Result.Result.Error(parseError!); + } + + options ??= new NexusClientOptions(); + NexusTlsMaterial? tls = options.ResolveTls(); + bool useHttps = options.ResolveUseHttps(tls); + + Robotico.Result.Result channelResult = NexusGrpcChannelFactory.CreateChannel(parsed!, tls, useHttps); + if (!channelResult.IsSuccess(out GrpcChannel? channel, out IError? channelError)) + { + return Robotico.Result.Result.Error(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())); + } + + /// Calls NexusHealthService.Check and succeeds only when the server reports ready. + 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)); + } + } + + /// Creates a thin document-plane façade (Ping + single-document upsert/get/delete) bound to this channel. + public Robotico.Result.Result CreateDocumentClient() + { + Robotico.Result.Result disposed = EnsureNotDisposed(); + return disposed.IsSuccess() + ? Robotico.Result.Result.Success(new NexusDocumentClient(_channel, Database, _bearerToken)) + : Robotico.Result.Result.Error(AsError(disposed)); + } + + /// Creates a thin relational-plane façade (Ping only) bound to this channel. + public Robotico.Result.Result CreateRelationalClient() + { + Robotico.Result.Result disposed = EnsureNotDisposed(); + return disposed.IsSuccess() + ? Robotico.Result.Result.Success(new NexusRelationalClient(_channel, Database, _bearerToken)) + : Robotico.Result.Result.Error(AsError(disposed)); + } + + /// Creates the generated document-plane Wire.V1 client bound to this channel (full RPC surface). + public Robotico.Result.Result CreateDocumentWireClient() + { + Robotico.Result.Result disposed = EnsureNotDisposed(); + return disposed.IsSuccess() + ? Robotico.Result.Result.Success(new NexusDocumentEngineService.NexusDocumentEngineServiceClient(_channel)) + : Robotico.Result.Result.Error(AsError(disposed)); + } + + /// Creates the generated relational-plane Wire.V1 client bound to this channel (full RPC surface). + public Robotico.Result.Result CreateRelationalWireClient() + { + Robotico.Result.Result disposed = EnsureNotDisposed(); + return disposed.IsSuccess() + ? Robotico.Result.Result.Success(new NexusRelationalEngineService.NexusRelationalEngineServiceClient(_channel)) + : Robotico.Result.Result.Error(AsError(disposed)); + } + + /// Metadata headers a caller can attach to a manual RPC (document/relational clients) for this database. + public Metadata BuildCallMetadata(string? databaseNameOverride = null) => + NexusCallMetadata.Build(string.IsNullOrWhiteSpace(databaseNameOverride) ? Database : databaseNameOverride, _bearerToken); + + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + _channel.Dispose(); + } + + /// + 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."); + } +} diff --git a/src/Robotico.Nexus.Client/NexusClientOptions.cs b/src/Robotico.Nexus.Client/NexusClientOptions.cs new file mode 100644 index 0000000..867281d --- /dev/null +++ b/src/Robotico.Nexus.Client/NexusClientOptions.cs @@ -0,0 +1,49 @@ +namespace Robotico.Nexus.Client; + +/// +/// Explicit connection options for . When omitted, TLS material +/// falls back to and the bearer token falls back +/// to the NEXUS_WIRE_BEARER / NEXUS_WIRE_DEFAULT_BEARER environment variables (same +/// names as Mishima.Nexus.Wire.Clients — POLA). +/// +public sealed class NexusClientOptions +{ + /// Explicit TLS/mTLS material; overrides environment discovery when set. + public NexusTlsMaterial? Tls { get; init; } + + /// + /// Forces the transport scheme when is : for + /// https://, for plaintext http:// (h2c, dev/loopback only). + /// Defaults to plaintext when unset and no TLS material is available. + /// + public bool? UseHttps { get; init; } + + /// Bearer token attached as an Authorization header on every call. + public string? BearerToken { get; init; } + + /// Resolves TLS material: explicit option first, else process environment. + internal NexusTlsMaterial? ResolveTls() => Tls ?? NexusTlsMaterial.TryFromEnvironment(); + + /// Resolves the bearer token: explicit option first, else process environment. + internal string? ResolveBearerToken() => + BearerToken + ?? Environment.GetEnvironmentVariable("NEXUS_WIRE_BEARER") + ?? Environment.GetEnvironmentVariable("NEXUS_WIRE_DEFAULT_BEARER"); + + /// Resolves whether to dial https, honoring TLS material, explicit option, then env NEXUS_TLS_MODE. + 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); + } +} diff --git a/src/Robotico.Nexus.Client/NexusConnectionUri.cs b/src/Robotico.Nexus.Client/NexusConnectionUri.cs new file mode 100644 index 0000000..9d95cae --- /dev/null +++ b/src/Robotico.Nexus.Client/NexusConnectionUri.cs @@ -0,0 +1,80 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Robotico.Nexus.Client; + +/// +/// Parses Robotico.Nexus.Client connection URIs: nexus+graph://, nexus+time://, +/// nexus+document://, nexus+relational://. Duplicated (not referenced) from +/// Mishima.Nexus.Abstractions.NexusConnectionUri so this package never pulls the Nexus +/// F# config / domain assemblies — see docs/adr/0001-nexus-client-monolith.adoc. +/// +public static class NexusConnectionUri +{ + /// Parses a Nexus client connection URI. + [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 TryParse(string connectionUri) + { + if (string.IsNullOrWhiteSpace(connectionUri)) + { + return Robotico.Result.Result.Error(NexusErrors.Error("Connection URI is required.", NexusErrors.UriInvalid)); + } + + if (!Uri.TryCreate(connectionUri, UriKind.Absolute, out Uri? uri)) + { + return Robotico.Result.Result.Error(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(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(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(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(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)); + } + + /// Canonical default logical database name per scheme when the URI path is empty. + public static string DefaultDatabase(string scheme) => scheme switch + { + NexusUriSchemes.Graph => "pathways", + NexusUriSchemes.Time => "telemetry", + NexusUriSchemes.Document => "catalog", + NexusUriSchemes.Relational => "ledger", + _ => "primary", + }; +} diff --git a/src/Robotico.Nexus.Client/NexusConnectionUriParts.cs b/src/Robotico.Nexus.Client/NexusConnectionUriParts.cs new file mode 100644 index 0000000..f77ecd1 --- /dev/null +++ b/src/Robotico.Nexus.Client/NexusConnectionUriParts.cs @@ -0,0 +1,8 @@ +namespace Robotico.Nexus.Client; + +/// Parsed connection target. +/// One of the values. +/// Target host name or address. +/// Explicit target port (never inferred). +/// Logical database name from the URI path, or when omitted. +public sealed record NexusConnectionUriParts(string Scheme, string Host, int Port, string? Database); diff --git a/src/Robotico.Nexus.Client/NexusErrors.cs b/src/Robotico.Nexus.Client/NexusErrors.cs new file mode 100644 index 0000000..d09f227 --- /dev/null +++ b/src/Robotico.Nexus.Client/NexusErrors.cs @@ -0,0 +1,46 @@ +using Robotico.Result.Errors; + +namespace Robotico.Nexus.Client; + +/// Stable error codes for . +public static class NexusErrors +{ + /// General Nexus client failure. + public const string Code = "NXS_NEXUS"; + + /// Connection URI failed to parse or uses an unsupported form. + public const string UriInvalid = "NXS_URI_INVALID"; + + /// Connection URI scheme is recognized but not supported by this client. + public const string UnsupportedScheme = "NXS_URI_UNSUPPORTED_SCHEME"; + + /// The gRPC channel could not be created (TLS material, address, or transport failure). + public const string ConnectFailed = "NXS_CONNECT_FAILED"; + + /// TLS/mTLS material was rejected or missing required files. + public const string TlsRejected = "NXS_TLS_REJECTED"; + + /// Create was called for a database that already answers to Ping. + public const string AlreadyExists = "NXS_ALREADY_EXISTS"; + + /// Requested resource was not found on the remote engine. + public const string NotFound = "NXS_NOT_FOUND"; + + /// Invalid argument (empty name, bad range, unknown wire type). + public const string InvalidArgument = "NXS_INVALID_ARGUMENT"; + + /// Operation attempted on a disposed client/session. + public const string Disposed = "NXS_DISPOSED"; + + /// Remote engine is unreachable or the call exceeded its deadline. + public const string Unavailable = "NXS_UNAVAILABLE"; + + /// Generic wire/gRPC failure not covered by a more specific code. + public const string WireFailed = "NXS_WIRE_FAILED"; + + /// Creates a with . + public static SimpleError Error(string message) => new(message, Code); + + /// Creates a with an explicit code. + public static SimpleError Error(string message, string code) => new(message, code); +} diff --git a/src/Robotico.Nexus.Client/NexusGrpcChannelFactory.cs b/src/Robotico.Nexus.Client/NexusGrpcChannelFactory.cs new file mode 100644 index 0000000..b482fc9 --- /dev/null +++ b/src/Robotico.Nexus.Client/NexusGrpcChannelFactory.cs @@ -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; + +/// Builds gRPC channels (TLS/mTLS or plaintext h2c) for a parsed Nexus connection URI. +internal static class NexusGrpcChannelFactory +{ + /// Resolves the wire address (scheme://host:port) for a parsed URI. + internal static string ResolveAddress(NexusConnectionUriParts parsed, bool useHttps) => + useHttps ? $"https://{parsed.Host}:{parsed.Port}" : $"http://{parsed.Host}:{parsed.Port}"; + + /// Creates a channel, applying mTLS client material when provided. + [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 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(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(NexusErrors.Error($"Failed to create channel for '{address}': {ex.Message}", NexusErrors.ConnectFailed)); + } + } + + /// HTTP/2 handler tuned for low-latency unary RPCs (loopback and LAN). + private static SocketsHttpHandler CreateTunedSocketsHandler() => + new() + { + EnableMultipleHttp2Connections = true, + PooledConnectionIdleTimeout = Timeout.InfiniteTimeSpan, + KeepAlivePingDelay = TimeSpan.FromSeconds(60), + KeepAlivePingTimeout = TimeSpan.FromSeconds(30), + ConnectTimeout = TimeSpan.FromSeconds(5), + }; +} diff --git a/src/Robotico.Nexus.Client/NexusTlsMaterial.cs b/src/Robotico.Nexus.Client/NexusTlsMaterial.cs new file mode 100644 index 0000000..1fa3fb8 --- /dev/null +++ b/src/Robotico.Nexus.Client/NexusTlsMaterial.cs @@ -0,0 +1,78 @@ +namespace Robotico.Nexus.Client; + +/// +/// Per-connection TLS/mTLS material for a Nexus machine credential. Uses the same environment +/// variable names as Mishima.Nexus.Wire.Clients.WireTlsMaterial (POLA) without depending on +/// that assembly — see docs/adr/0001-nexus-client-monolith.adoc. +/// +public sealed class NexusTlsMaterial +{ + /// Creates material with required PEM paths. + 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; + } + + /// CA PEM used to trust the Nexus server certificate. + public string CaCertificateFile { get; } + + /// Client certificate PEM (machine credential). + public string ClientCertificateFile { get; } + + /// Client private key PEM. + public string ClientKeyFile { get; } + + /// When true, dial https:// gRPC. + public bool UseHttps { get; } + + /// + /// Loads material from process env: NEXUS_TLS_CA_FILE, NEXUS_TLS_CLIENT_CERT_FILE, + /// NEXUS_TLS_CLIENT_KEY_FILE, optional NEXUS_TLS_MODE (defaults to https). + /// Returns when the required files are absent (insecure/dev connection). + /// + 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); + } + + /// Validates that all files exist. + 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}"); + } + } +} diff --git a/src/Robotico.Nexus.Client/NexusUriSchemes.cs b/src/Robotico.Nexus.Client/NexusUriSchemes.cs new file mode 100644 index 0000000..41aad9f --- /dev/null +++ b/src/Robotico.Nexus.Client/NexusUriSchemes.cs @@ -0,0 +1,20 @@ +namespace Robotico.Nexus.Client; + +/// Supported Nexus wire schemes for . +public static class NexusUriSchemes +{ + /// Native graph plane wire. + public const string Graph = "nexus+graph"; + + /// Native time plane wire. + public const string Time = "nexus+time"; + + /// Native document plane wire. + public const string Document = "nexus+document"; + + /// Native relational plane wire. + public const string Relational = "nexus+relational"; + + /// SQL compatibility wire — not supported by this client (use Mishima.Nexus.Sql directly). + public const string Sql = "nexus+sql"; +} diff --git a/src/Robotico.Nexus.Client/NexusWireStatus.cs b/src/Robotico.Nexus.Client/NexusWireStatus.cs new file mode 100644 index 0000000..f5d0ef3 --- /dev/null +++ b/src/Robotico.Nexus.Client/NexusWireStatus.cs @@ -0,0 +1,68 @@ +using Grpc.Core; +using Mishima.Nexus.Wire.V1; +using Robotico.Result.Errors; + +namespace Robotico.Nexus.Client; + +/// Maps wire envelopes and gRPC failures to . +internal static class NexusWireStatus +{ + private const string OkCode = "OK"; + + /// Returns success when reports OK; otherwise a coded error. + 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)); + } + + /// Maps status to , carrying on success. + internal static Robotico.Result.Result ToResult(NexusStatus? status, T value) + { + Robotico.Result.Result plain = ToResult(status); + return plain.IsError(out IError? error) ? Robotico.Result.Result.Error(error!) : Robotico.Result.Result.Success(value); + } + + /// True when reports OK. + internal static bool IsOk(NexusStatus? status) => + status is not null && string.Equals(status.Code, OkCode, StringComparison.Ordinal); + + /// Maps a failed gRPC call into a coded . + 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); + } +} diff --git a/src/Robotico.Nexus.Client/Relational/NexusRelationalClient.cs b/src/Robotico.Nexus.Client/Relational/NexusRelationalClient.cs new file mode 100644 index 0000000..5cecde2 --- /dev/null +++ b/src/Robotico.Nexus.Client/Relational/NexusRelationalClient.cs @@ -0,0 +1,41 @@ +using Grpc.Core; +using Grpc.Net.Client; +using Mishima.Nexus.Wire.V1; + +namespace Robotico.Nexus.Client.Relational; + +/// +/// Thin relational-plane connect helper: liveness only () — enough for +/// MVP smoke usage. This is not a full relational façade; use the generated +/// +/// directly (via ) for row-level RPCs. +/// +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; + } + + /// Calls NexusRelationalEngineService.Ping. + 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); +} diff --git a/src/Robotico.Nexus.Client/Robotico.Nexus.Client.csproj b/src/Robotico.Nexus.Client/Robotico.Nexus.Client.csproj new file mode 100644 index 0000000..6b2c63b --- /dev/null +++ b/src/Robotico.Nexus.Client/Robotico.Nexus.Client.csproj @@ -0,0 +1,42 @@ + + + Robotico.Nexus.Client + Robotico.Nexus.Client + https://brokkr.robotico.dev/robotico/robotico-nexus-client-csharp + https://brokkr.robotico.dev/robotico/robotico-nexus-client-csharp + main + true + true + snupkg + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Robotico.Nexus.Client/Time/NexusTimeSession.cs b/src/Robotico.Nexus.Client/Time/NexusTimeSession.cs new file mode 100644 index 0000000..974da8e --- /dev/null +++ b/src/Robotico.Nexus.Client/Time/NexusTimeSession.cs @@ -0,0 +1,364 @@ +using Grpc.Core; +using Mishima.Nexus.Wire.V1; +using Robotico.Result.Errors; +using Robotico.Time; + +namespace Robotico.Nexus.Client.Time; + +/// +/// bound to one Nexus logical time database. is +/// synthesized from NexusTimeEngineService.Doctor — / +/// are the logical nexus:// identity (Nexus does not expose +/// server-side filesystem paths to remote clients), and is +/// always 0 (the remote engine, not this client, enforces any cap). +/// +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; + } + + /// + public Robotico.Result.Result Doctor() + { + Robotico.Result.Result disposed = EnsureNotDisposed(); + if (disposed.IsError(out IError? disposedError)) + { + return Robotico.Result.Result.Error(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); + }); + } + + /// + public Robotico.Result.Result RegisterSeries(TimeSeriesKey key) + { + Robotico.Result.Result disposed = EnsureNotDisposed(); + if (disposed.IsError(out IError? disposedError)) + { + return Robotico.Result.Result.Error(disposedError!); + } + + if (key is null || string.IsNullOrWhiteSpace(key.MetricName)) + { + return Robotico.Result.Result.Error(TimeErrors.Error("Metric name is required.", TimeErrors.InvalidArgument)); + } + + return InvokeGeneric(() => + { + TimeRegisterSeriesRequest request = new() { MetricName = key.MetricName.Trim() }; + foreach (KeyValuePair 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); + }); + } + + /// + 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); + }); + } + + /// + public Robotico.Result.Result AppendSampleBatch(ulong seriesId, IReadOnlyList 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); + }); + } + + /// + public Robotico.Result.Result> 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>(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 samples = + [.. response.Samples.Select(static s => new TimeSample(s.TimestampUnixNanos, s.Value))]; + return NexusWireStatus.ToResult(response.Status, samples); + }); + } + + /// + public Robotico.Result.Result Last(ulong seriesId) + { + Robotico.Result.Result disposed = EnsureNotDisposed(); + if (disposed.IsError(out IError? disposedError)) + { + return Robotico.Result.Result.Error(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); + }); + } + + /// + public Robotico.Result.Result RegisterEventTable(TimeEventTableSchema schema) + { + Robotico.Result.Result disposed = EnsureNotDisposed(); + if (disposed.IsError(out IError? disposedError)) + { + return Robotico.Result.Result.Error(disposedError!); + } + + if (schema is null || string.IsNullOrWhiteSpace(schema.TableName) || schema.Fields.Count is 0) + { + return Robotico.Result.Result.Error(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); + }); + } + + /// + public Robotico.Result.Result AppendEvent(ulong tableId, long timestampUnixNanos, IReadOnlyList cells) + { + Robotico.Result.Result disposed = EnsureNotDisposed(); + if (disposed.IsError(out IError? disposedError)) + { + return Robotico.Result.Result.Error(disposedError!); + } + + if (cells is null || cells.Count is 0) + { + return Robotico.Result.Result.Error(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); + }); + } + + /// + public Robotico.Result.Result> 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>(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)); + }); + } + + /// + public Robotico.Result.Result> 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>(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)); + }); + } + + /// + public Robotico.Result.Result> 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>(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 hits = + [.. response.Hits.Select(static h => new TimeSearchHit(h.EventId, h.TimestampUnixNanos, h.Score))]; + return NexusWireStatus.ToResult(response.Status, hits); + }); + } + + /// + public void Dispose() => _disposed = true; + + private static IReadOnlyList ToWideEvents(IEnumerable 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 call) + { + try + { + return call(); + } + catch (RpcException ex) + { + return Robotico.Result.Result.Error(NexusWireStatus.FromException(ex)); + } + } + + private static Robotico.Result.Result InvokeGeneric(Func> call) + { + try + { + return call(); + } + catch (RpcException ex) + { + return Robotico.Result.Result.Error(NexusWireStatus.FromException(ex)); + } + } +} diff --git a/src/Robotico.Nexus.Client/Time/NexusTimeStore.cs b/src/Robotico.Nexus.Client/Time/NexusTimeStore.cs new file mode 100644 index 0000000..e8d4b82 --- /dev/null +++ b/src/Robotico.Nexus.Client/Time/NexusTimeStore.cs @@ -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; + +/// +/// over the Nexus time plane gRPC service. Per POLA, the +/// databaseFilePath argument on every method is the logical database name (Nexus +/// x-nexus-database metadata value), not a filesystem path — see +/// docs/adr/0001-nexus-client-monolith.adoc. +/// +/// +/// Ping is an engine-liveness probe on the Nexus time host, not a per-database +/// existence check, so will typically report +/// once the engine is reachable. Use for greenfield databases — the Nexus +/// host provisions the logical database lazily on the first RPC that touches its state. +/// +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; + } + + /// + public Robotico.Result.Result Create(string databaseFilePath, int maxActiveSeries = 0) + { + Robotico.Result.Result nameResult = ResolveName(databaseFilePath); + if (!nameResult.IsSuccess(out string? name, out IError? nameError)) + { + return Robotico.Result.Result.Error(nameError!); + } + + Robotico.Result.Result reachable = PingReachable(name!); + if (!reachable.IsSuccess(out bool alreadyUp, out IError? pingError)) + { + return Robotico.Result.Result.Error(pingError!); + } + + if (alreadyUp) + { + return Robotico.Result.Result.Error(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!); + } + + /// + public Robotico.Result.Result Open(string databaseFilePath) + { + Robotico.Result.Result nameResult = ResolveName(databaseFilePath); + return nameResult.IsSuccess(out string? name, out IError? nameError) + ? OpenCore(name!) + : Robotico.Result.Result.Error(nameError!); + } + + /// + public Robotico.Result.Result 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.Success; the caller is responsible for disposing it.")] + private Robotico.Result.Result OpenCore(string name) + { + Robotico.Result.Result reachable = PingReachable(name); + if (!reachable.IsSuccess(out bool ok, out IError? pingError)) + { + return Robotico.Result.Result.Error(pingError!); + } + + return ok + ? Robotico.Result.Result.Success(new NexusTimeSession(_client, name, _bearerToken)) + : Robotico.Result.Result.Error(NexusErrors.Error($"Time engine ping failed for database '{name}'.", NexusErrors.Unavailable)); + } + + private Robotico.Result.Result 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(NexusWireStatus.FromException(ex)); + } + } + + private Robotico.Result.Result ResolveName(string databaseFilePath) + { + if (string.IsNullOrWhiteSpace(databaseFilePath)) + { + return string.IsNullOrWhiteSpace(_defaultDatabaseName) + ? Robotico.Result.Result.Error(NexusErrors.Error("Logical database name is required.", NexusErrors.InvalidArgument)) + : Robotico.Result.Result.Success(_defaultDatabaseName); + } + + return Robotico.Result.Result.Success(databaseFilePath.Trim()); + } +} diff --git a/src/Robotico.Nexus.Client/Time/TimeWireMapping.cs b/src/Robotico.Nexus.Client/Time/TimeWireMapping.cs new file mode 100644 index 0000000..e759bb4 --- /dev/null +++ b/src/Robotico.Nexus.Client/Time/TimeWireMapping.cs @@ -0,0 +1,66 @@ +using Mishima.Nexus.Wire.V1; +using Robotico.Time; + +namespace Robotico.Nexus.Client.Time; + +/// Bidirectional mapping between Robotico.Time DTOs and Wire.V1 time messages. +internal static class TimeWireMapping +{ + /// Maps a port-level cell to its wire representation, keyed by . + 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; + } + + /// Maps a wire cell back to the port-level DTO, keyed by . + 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), + }; + } + + /// Maps a port-level field index kind to the wire enum. + 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, + }; +} diff --git a/tests/Robotico.Nexus.Client.Tests/Document/NexusDocumentClientTests.cs b/tests/Robotico.Nexus.Client.Tests/Document/NexusDocumentClientTests.cs new file mode 100644 index 0000000..d93d74d --- /dev/null +++ b/tests/Robotico.Nexus.Client.Tests/Document/NexusDocumentClientTests.cs @@ -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 connected = NexusClient.Connect(_host.ConnectionUri(NexusUriSchemes.Document)); + Assert.True(connected.IsSuccess(out NexusClient? client, out _)); + _client = client!; + Robotico.Result.Result 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 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 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 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 connected = NexusClient.Connect( + _host.ConnectionUri(NexusUriSchemes.Document, FakeNexusTriggers.PingReportsFailure)); + Assert.True(connected.IsSuccess(out NexusClient? client, out _)); + using NexusClient failingClient = client!; + Robotico.Result.Result 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); + } +} diff --git a/tests/Robotico.Nexus.Client.Tests/Graph/NexusGraphSessionTests.cs b/tests/Robotico.Nexus.Client.Tests/Graph/NexusGraphSessionTests.cs new file mode 100644 index 0000000..5ff42d3 --- /dev/null +++ b/tests/Robotico.Nexus.Client.Tests/Graph/NexusGraphSessionTests.cs @@ -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 connected = NexusClient.Connect(_host.ConnectionUri(NexusUriSchemes.Graph)); + Assert.True(connected.IsSuccess(out NexusClient? client, out _)); + _client = client!; + Robotico.Result.Result 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 upserted = _session.UpsertVertex(1, ["Person"]); + Assert.True(upserted.IsSuccess()); + + Robotico.Result.Result 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 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 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 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 upserted = _session.UpsertEdge(1, 2, "WorksAt"); + Assert.True(upserted.IsSuccess()); + + Robotico.Result.Result 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 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 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 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 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 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 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 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 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 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); + } +} diff --git a/tests/Robotico.Nexus.Client.Tests/Graph/NexusGraphStoreTests.cs b/tests/Robotico.Nexus.Client.Tests/Graph/NexusGraphStoreTests.cs new file mode 100644 index 0000000..61c4469 --- /dev/null +++ b/tests/Robotico.Nexus.Client.Tests/Graph/NexusGraphStoreTests.cs @@ -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 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 result = _client.Graph.Open("mydb"); + + Assert.True(result.IsSuccess(out IGraphSession? session, out _)); + session!.Dispose(); + } + + [Fact] + public void Open_UsesDefaultDatabaseName_WhenPathBlank() + { + Robotico.Result.Result 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 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 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 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 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 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 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); + } +} diff --git a/tests/Robotico.Nexus.Client.Tests/NexusCallMetadataTests.cs b/tests/Robotico.Nexus.Client.Tests/NexusCallMetadataTests.cs new file mode 100644 index 0000000..a1f80a1 --- /dev/null +++ b/tests/Robotico.Nexus.Client.Tests/NexusCallMetadataTests.cs @@ -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")); + } +} diff --git a/tests/Robotico.Nexus.Client.Tests/NexusClientOptionsTests.cs b/tests/Robotico.Nexus.Client.Tests/NexusClientOptionsTests.cs new file mode 100644 index 0000000..659e289 --- /dev/null +++ b/tests/Robotico.Nexus.Client.Tests/NexusClientOptionsTests.cs @@ -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)); + } +} diff --git a/tests/Robotico.Nexus.Client.Tests/NexusClientTests.cs b/tests/Robotico.Nexus.Client.Tests/NexusClientTests.cs new file mode 100644 index 0000000..fdcdb6b --- /dev/null +++ b/tests/Robotico.Nexus.Client.Tests/NexusClientTests.cs @@ -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 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 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 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 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 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 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 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 result = + client.CreateDocumentWireClient(); + + Assert.True(result.IsSuccess()); + } + + [Fact] + public void CreateDocumentWireClient_Fails_AfterDispose() + { + NexusClient client = Connect(NexusUriSchemes.Document); + client.Dispose(); + + Robotico.Result.Result 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 result = + client.CreateRelationalWireClient(); + + Assert.True(result.IsSuccess()); + } + + [Fact] + public void CreateRelationalWireClient_Fails_AfterDispose() + { + NexusClient client = Connect(NexusUriSchemes.Relational); + client.Dispose(); + + Robotico.Result.Result 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 result = NexusClient.Connect(_host.ConnectionUri(scheme, database)); + Assert.True(result.IsSuccess(out NexusClient? client, out _)); + return client!; + } +} diff --git a/tests/Robotico.Nexus.Client.Tests/NexusConnectionUriTests.cs b/tests/Robotico.Nexus.Client.Tests/NexusConnectionUriTests.cs new file mode 100644 index 0000000..144e018 --- /dev/null +++ b/tests/Robotico.Nexus.Client.Tests/NexusConnectionUriTests.cs @@ -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 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 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 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 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 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 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 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 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 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)); + } +} diff --git a/tests/Robotico.Nexus.Client.Tests/NexusErrorsTests.cs b/tests/Robotico.Nexus.Client.Tests/NexusErrorsTests.cs new file mode 100644 index 0000000..365d623 --- /dev/null +++ b/tests/Robotico.Nexus.Client.Tests/NexusErrorsTests.cs @@ -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); + } +} diff --git a/tests/Robotico.Nexus.Client.Tests/NexusGrpcChannelFactoryTests.cs b/tests/Robotico.Nexus.Client.Tests/NexusGrpcChannelFactoryTests.cs new file mode 100644 index 0000000..4dffb52 --- /dev/null +++ b/tests/Robotico.Nexus.Client.Tests/NexusGrpcChannelFactoryTests.cs @@ -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 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 result = NexusGrpcChannelFactory.CreateChannel(parsed, material, useHttps: true); + + Assert.True(result.IsError(out IError? error)); + Assert.Equal(NexusErrors.TlsRejected, error!.Code); + } +} diff --git a/tests/Robotico.Nexus.Client.Tests/NexusTlsMaterialTests.cs b/tests/Robotico.Nexus.Client.Tests/NexusTlsMaterialTests.cs new file mode 100644 index 0000000..be292bb --- /dev/null +++ b/tests/Robotico.Nexus.Client.Tests/NexusTlsMaterialTests.cs @@ -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(() => new NexusTlsMaterial(string.Empty, "cert", "key")); + Assert.Throws(() => new NexusTlsMaterial("ca", string.Empty, "key")); + Assert.Throws(() => 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(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); + } + } +} diff --git a/tests/Robotico.Nexus.Client.Tests/NexusWireStatusTests.cs b/tests/Robotico.Nexus.Client.Tests/NexusWireStatusTests.cs new file mode 100644 index 0000000..7a34858 --- /dev/null +++ b/tests/Robotico.Nexus.Client.Tests/NexusWireStatusTests.cs @@ -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 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 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); + } +} diff --git a/tests/Robotico.Nexus.Client.Tests/Relational/NexusRelationalClientTests.cs b/tests/Robotico.Nexus.Client.Tests/Relational/NexusRelationalClientTests.cs new file mode 100644 index 0000000..6aea3c5 --- /dev/null +++ b/tests/Robotico.Nexus.Client.Tests/Relational/NexusRelationalClientTests.cs @@ -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 connected = NexusClient.Connect(_host.ConnectionUri(NexusUriSchemes.Relational)); + Assert.True(connected.IsSuccess(out NexusClient? client, out _)); + _client = client!; + Robotico.Result.Result 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 connected = NexusClient.Connect( + _host.ConnectionUri(NexusUriSchemes.Relational, FakeNexusTriggers.PingReportsFailure)); + Assert.True(connected.IsSuccess(out NexusClient? client, out _)); + using NexusClient failingClient = client!; + Robotico.Result.Result 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 connected = NexusClient.Connect( + _host.ConnectionUri(NexusUriSchemes.Relational, FakeNexusTriggers.PingThrows)); + Assert.True(connected.IsSuccess(out NexusClient? client, out _)); + using NexusClient failingClient = client!; + Robotico.Result.Result 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); + } +} diff --git a/tests/Robotico.Nexus.Client.Tests/Robotico.Nexus.Client.Tests.csproj b/tests/Robotico.Nexus.Client.Tests/Robotico.Nexus.Client.Tests.csproj new file mode 100644 index 0000000..305fdf4 --- /dev/null +++ b/tests/Robotico.Nexus.Client.Tests/Robotico.Nexus.Client.Tests.csproj @@ -0,0 +1,30 @@ + + + Robotico.Nexus.Client.Tests + Robotico.Nexus.Client.Tests + Robotico.Nexus.Client.Tests + false + true + net10.0 + $(NoWarn);CA1054;CA1055;CA1062;CA1063;CA1707;CA1515;CA1711;CA1816;CA1849;CA1861;CA2000;CA2007;CA2234;IDE0004;IDE0005;IDE0022;IDE0090;MA0004;MA0040;MA0042;EnableGenerateDocumentationFile + false + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/tests/Robotico.Nexus.Client.Tests/TestSupport/FakeDocumentEngineService.cs b/tests/Robotico.Nexus.Client.Tests/TestSupport/FakeDocumentEngineService.cs new file mode 100644 index 0000000..6e9cf90 --- /dev/null +++ b/tests/Robotico.Nexus.Client.Tests/TestSupport/FakeDocumentEngineService.cs @@ -0,0 +1,45 @@ +using Grpc.Core; +using Mishima.Nexus.Wire.V1; + +namespace Robotico.Nexus.Client.Tests.TestSupport; + +/// +/// In-memory fake standing in for a real Nexus document engine host — only the RPCs used by +/// (Ping + single-document CRUD). +/// +public sealed class FakeDocumentEngineService : NexusDocumentEngineService.NexusDocumentEngineServiceBase +{ + private readonly Dictionary<(string Collection, string Id), string> _documents = []; + + /// + public override Task Ping(DocumentPingRequest request, ServerCallContext context) + { + NexusStatus? failure = FakeNexusTriggers.TryStatusFailure(context); + return Task.FromResult(new DocumentPingResponse { Status = failure ?? NexusStatuses.Ok, EngineVersion = "fake" }); + } + + /// + public override Task UpsertDocument(DocumentUpsertRequest request, ServerCallContext context) + { + _documents[(request.CollectionName, request.DocumentId)] = request.JsonBody; + return Task.FromResult(new DocumentUpsertResponse { Status = NexusStatuses.Ok }); + } + + /// + public override Task 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 }); + } + + /// + public override Task DeleteDocument(DocumentDeleteRequest request, ServerCallContext context) + { + _documents.Remove((request.CollectionName, request.DocumentId)); + return Task.FromResult(new DocumentDeleteResponse { Status = NexusStatuses.Ok }); + } +} diff --git a/tests/Robotico.Nexus.Client.Tests/TestSupport/FakeGraphEngineService.cs b/tests/Robotico.Nexus.Client.Tests/TestSupport/FakeGraphEngineService.cs new file mode 100644 index 0000000..6d2e031 --- /dev/null +++ b/tests/Robotico.Nexus.Client.Tests/TestSupport/FakeGraphEngineService.cs @@ -0,0 +1,137 @@ +using Grpc.Core; +using Mishima.Nexus.Wire.V1; + +namespace Robotico.Nexus.Client.Tests.TestSupport; + +/// +/// In-memory fake standing in for a real Nexus graph engine host, exercising the exact wire +/// contract / NexusGraphSession +/// depend on. Ping honors database-name sentinels so tests +/// can drive both the happy path and the failure branches of the client. +/// +public sealed class FakeGraphEngineService : NexusGraphEngineService.NexusGraphEngineServiceBase +{ + private readonly Dictionary> _vertexLabels = []; + private readonly HashSet _registeredLabels = []; + private readonly HashSet _registeredEdgeTypes = []; + private readonly HashSet<(ulong Source, ulong Target, string EdgeType)> _edges = []; + private ulong _txn; + + /// + public override Task 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" }); + } + + /// + public override Task RegisterLabel(GraphRegisterLabelRequest request, ServerCallContext context) + { + _registeredLabels.Add(request.LabelName); + return Task.FromResult(new GraphRegisterLabelResponse { Status = NexusStatuses.Ok }); + } + + /// + public override Task RegisterEdgeType(GraphRegisterEdgeTypeRequest request, ServerCallContext context) + { + _registeredEdgeTypes.Add(request.EdgeTypeName); + return Task.FromResult(new GraphRegisterEdgeTypeResponse { Status = NexusStatuses.Ok }); + } + + /// + public override Task UpsertVertex(GraphUpsertVertexRequest request, ServerCallContext context) + { + _vertexLabels[request.VertexId] = [.. request.LabelNames]; + return Task.FromResult(new GraphUpsertVertexResponse { Status = NexusStatuses.Ok, CommittedTxnId = ++_txn }); + } + + /// + public override Task DeleteVertex(GraphDeleteVertexRequest request, ServerCallContext context) + { + _vertexLabels.Remove(request.VertexId); + return Task.FromResult(new GraphDeleteVertexResponse { Status = NexusStatuses.Ok, CommittedTxnId = ++_txn }); + } + + /// + public override Task GetVertex(GraphGetVertexRequest request, ServerCallContext context) + { + if (!_vertexLabels.TryGetValue(request.VertexId, out List? 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); + } + + /// + public override Task UpsertEdge(GraphUpsertEdgeRequest request, ServerCallContext context) + { + _edges.Add((request.SourceVertexId, request.TargetVertexId, request.EdgeTypeName)); + return Task.FromResult(new GraphUpsertEdgeResponse { Status = NexusStatuses.Ok, CommittedTxnId = ++_txn }); + } + + /// + public override Task DeleteEdge(GraphDeleteEdgeRequest request, ServerCallContext context) + { + _edges.Remove((request.SourceVertexId, request.TargetVertexId, request.EdgeTypeName)); + return Task.FromResult(new GraphDeleteEdgeResponse { Status = NexusStatuses.Ok, CommittedTxnId = ++_txn }); + } + + /// + public override Task 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, + }); + } + + /// + public override Task 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); + } + + /// + public override Task ListVerticesByLabel(GraphListVerticesByLabelRequest request, ServerCallContext context) + { + List 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); + } +} diff --git a/tests/Robotico.Nexus.Client.Tests/TestSupport/FakeHealthService.cs b/tests/Robotico.Nexus.Client.Tests/TestSupport/FakeHealthService.cs new file mode 100644 index 0000000..33bde0f --- /dev/null +++ b/tests/Robotico.Nexus.Client.Tests/TestSupport/FakeHealthService.cs @@ -0,0 +1,19 @@ +using Grpc.Core; +using Mishima.Nexus.Wire.V1; + +namespace Robotico.Nexus.Client.Tests.TestSupport; + +/// In-memory fake standing in for NexusHealthService. +public sealed class FakeHealthService : NexusHealthService.NexusHealthServiceBase +{ + /// When , reports the server as not ready. + public bool Ready { get; set; } = true; + + /// + public override Task 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); + } +} diff --git a/tests/Robotico.Nexus.Client.Tests/TestSupport/FakeNexusTriggers.cs b/tests/Robotico.Nexus.Client.Tests/TestSupport/FakeNexusTriggers.cs new file mode 100644 index 0000000..bdff7b3 --- /dev/null +++ b/tests/Robotico.Nexus.Client.Tests/TestSupport/FakeNexusTriggers.cs @@ -0,0 +1,32 @@ +using Grpc.Core; +using Mishima.Nexus.Wire.V1; + +namespace Robotico.Nexus.Client.Tests.TestSupport; + +/// +/// Sentinel logical-database names (sent via the x-nexus-database metadata header) that let +/// tests deterministically drive the failure branches of Ping-fronted RPCs without a real +/// unreachable engine. +/// +public static class FakeNexusTriggers +{ + /// Ping succeeds at the transport level but the engine reports a non-OK status. + public const string PingReportsFailure = "trigger-ping-status-failure"; + + /// Ping fails at the transport level (simulated ). + public const string PingThrows = "trigger-ping-rpc-exception"; + + /// Returns a failure when the request's database header is , or throws when it is . + 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; + } +} diff --git a/tests/Robotico.Nexus.Client.Tests/TestSupport/FakeRelationalEngineService.cs b/tests/Robotico.Nexus.Client.Tests/TestSupport/FakeRelationalEngineService.cs new file mode 100644 index 0000000..92323aa --- /dev/null +++ b/tests/Robotico.Nexus.Client.Tests/TestSupport/FakeRelationalEngineService.cs @@ -0,0 +1,18 @@ +using Grpc.Core; +using Mishima.Nexus.Wire.V1; + +namespace Robotico.Nexus.Client.Tests.TestSupport; + +/// +/// In-memory fake standing in for a real Nexus relational engine host — only Ping, the sole +/// RPC used by . +/// +public sealed class FakeRelationalEngineService : NexusRelationalEngineService.NexusRelationalEngineServiceBase +{ + /// + public override Task Ping(RelationalPingRequest request, ServerCallContext context) + { + NexusStatus? failure = FakeNexusTriggers.TryStatusFailure(context); + return Task.FromResult(new RelationalPingResponse { Status = failure ?? NexusStatuses.Ok, EngineVersion = "fake" }); + } +} diff --git a/tests/Robotico.Nexus.Client.Tests/TestSupport/FakeTimeEngineService.cs b/tests/Robotico.Nexus.Client.Tests/TestSupport/FakeTimeEngineService.cs new file mode 100644 index 0000000..7f4c465 --- /dev/null +++ b/tests/Robotico.Nexus.Client.Tests/TestSupport/FakeTimeEngineService.cs @@ -0,0 +1,166 @@ +using Grpc.Core; +using Mishima.Nexus.Wire.V1; + +namespace Robotico.Nexus.Client.Tests.TestSupport; + +/// In-memory fake standing in for a real Nexus time engine host. +public sealed class FakeTimeEngineService : NexusTimeEngineService.NexusTimeEngineServiceBase +{ + private readonly Dictionary> _samplesBySeries = []; + private readonly Dictionary> _eventsByTable = []; + private readonly Dictionary> _fieldsByTable = []; + private ulong _nextSeriesId = 1; + private ulong _nextTableId = 1; + private ulong _nextEventId = 1; + + /// + public override Task Ping(TimePingRequest request, ServerCallContext context) + { + NexusStatus? failure = FakeNexusTriggers.TryStatusFailure(context); + return Task.FromResult(new TimePingResponse { Status = failure ?? NexusStatuses.Ok, EngineVersion = "fake" }); + } + + /// + public override Task 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, + }); + + /// + public override Task RegisterSeries(TimeRegisterSeriesRequest request, ServerCallContext context) + { + ulong id = _nextSeriesId++; + _samplesBySeries[id] = []; + return Task.FromResult(new TimeRegisterSeriesResponse { Status = NexusStatuses.Ok, SeriesId = id }); + } + + /// + public override Task AppendSample(TimeAppendSampleRequest request, ServerCallContext context) + { + if (!_samplesBySeries.TryGetValue(request.SeriesId, out List? 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 }); + } + + /// + public override Task AppendSampleBatch(TimeAppendSampleBatchRequest request, ServerCallContext context) + { + if (!_samplesBySeries.TryGetValue(request.SeriesId, out List? 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 }); + } + + /// + public override Task QueryRange(TimeQueryRangeRequest request, ServerCallContext context) + { + TimeQueryRangeResponse response = new() { Status = NexusStatuses.Ok }; + if (_samplesBySeries.TryGetValue(request.SeriesId, out List? samples)) + { + IEnumerable 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); + } + + /// + public override Task Last(TimeLastRequest request, ServerCallContext context) + { + if (_samplesBySeries.TryGetValue(request.SeriesId, out List? 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 }); + } + + /// + public override Task RegisterEventTable(TimeRegisterEventTableRequest request, ServerCallContext context) + { + ulong id = _nextTableId++; + _eventsByTable[id] = []; + _fieldsByTable[id] = [.. request.Fields]; + return Task.FromResult(new TimeRegisterEventTableResponse { Status = NexusStatuses.Ok, TableId = id }); + } + + /// + public override Task AppendEvent(TimeAppendEventRequest request, ServerCallContext context) + { + if (!_eventsByTable.TryGetValue(request.TableId, out List? 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 }); + } + + /// + public override Task QueryEvents(TimeQueryEventsRequest request, ServerCallContext context) + { + TimeQueryEventsResponse response = new() { Status = NexusStatuses.Ok }; + if (_eventsByTable.TryGetValue(request.TableId, out List? events)) + { + IEnumerable 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); + } + + /// + public override Task LookupInverted(TimeLookupInvertedRequest request, ServerCallContext context) + { + TimeQueryEventsResponse response = new() { Status = NexusStatuses.Ok }; + if (_eventsByTable.TryGetValue(request.TableId, out List? events)) + { + IEnumerable 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); + } + + /// + public override Task SearchFullText(TimeSearchFullTextRequest request, ServerCallContext context) + { + TimeSearchFullTextResponse response = new() { Status = NexusStatuses.Ok }; + if (_eventsByTable.TryGetValue(request.TableId, out List? events)) + { + IEnumerable 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); + } +} diff --git a/tests/Robotico.Nexus.Client.Tests/TestSupport/NexusStatuses.cs b/tests/Robotico.Nexus.Client.Tests/TestSupport/NexusStatuses.cs new file mode 100644 index 0000000..f3e05a2 --- /dev/null +++ b/tests/Robotico.Nexus.Client.Tests/TestSupport/NexusStatuses.cs @@ -0,0 +1,16 @@ +using Mishima.Nexus.Wire.V1; + +namespace Robotico.Nexus.Client.Tests.TestSupport; + +/// Shared envelope builders for fake engine services. +public static class NexusStatuses +{ + /// A fresh success envelope (mirrors the real wire's "OK" convention). + public static NexusStatus Ok => new() { Code = "OK", Message = string.Empty }; + + /// A coded failure envelope. + public static NexusStatus Failure(string code, string message) => new() { Code = code, Message = message }; + + /// A not-found failure envelope. + public static NexusStatus NotFound(string message) => Failure("NXS_NOT_FOUND", message); +} diff --git a/tests/Robotico.Nexus.Client.Tests/TestSupport/NexusTestHost.cs b/tests/Robotico.Nexus.Client.Tests/TestSupport/NexusTestHost.cs new file mode 100644 index 0000000..bd4d89b --- /dev/null +++ b/tests/Robotico.Nexus.Client.Tests/TestSupport/NexusTestHost.cs @@ -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; + +/// +/// 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. +/// +public sealed class NexusTestHost : IAsyncDisposable +{ + private readonly WebApplication _app; + + private NexusTestHost(WebApplication app, int port, FakeHealthService health) + { + _app = app; + Port = port; + Health = health; + } + + /// Loopback port Kestrel bound to. + public int Port { get; } + + /// The health fake, so tests can flip . + public FakeHealthService Health { get; } + + /// Starts the host on an OS-assigned loopback port. + public static async Task 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(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + + WebApplication app = builder.Build(); + app.MapGrpcService(); + app.MapGrpcService(); + app.MapGrpcService(); + app.MapGrpcService(); + app.MapGrpcService(); + + await app.StartAsync(); + int port = new Uri(app.Urls.Single()).Port; + FakeHealthService health = app.Services.GetRequiredService(); + return new NexusTestHost(app, port, health); + } + + /// Builds a nexus+<scheme>://127.0.0.1:{port}/{database} connection URI for this host. + public string ConnectionUri(string scheme, string? database = null) => + string.IsNullOrEmpty(database) + ? $"{scheme}://127.0.0.1:{Port}" + : $"{scheme}://127.0.0.1:{Port}/{database}"; + + /// + public async ValueTask DisposeAsync() + { + await _app.StopAsync(); + await _app.DisposeAsync(); + } +} diff --git a/tests/Robotico.Nexus.Client.Tests/Time/NexusTimeSessionTests.cs b/tests/Robotico.Nexus.Client.Tests/Time/NexusTimeSessionTests.cs new file mode 100644 index 0000000..b9b792c --- /dev/null +++ b/tests/Robotico.Nexus.Client.Tests/Time/NexusTimeSessionTests.cs @@ -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 NoLabels = new Dictionary(); + + private NexusTestHost _host = null!; + private NexusClient _client = null!; + private ITimeSession _session = null!; + + public async Task InitializeAsync() + { + _host = await NexusTestHost.StartAsync(); + Robotico.Result.Result connected = NexusClient.Connect(_host.ConnectionUri(NexusUriSchemes.Time)); + Assert.True(connected.IsSuccess(out NexusClient? client, out _)); + _client = client!; + Robotico.Result.Result 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 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 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 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> queried = _session.QueryRange(seriesId, 0, 2000, 10); + + Assert.True(queried.IsSuccess(out IReadOnlyList? 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> queried = _session.QueryRange(seriesId, 0, 3000, 10); + Assert.True(queried.IsSuccess(out IReadOnlyList? 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 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 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 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 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 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 appended = _session.AppendEvent(tableId, 1000, [new TimeCell("Utf8", Utf8: "/api/foo")]); + Assert.True(appended.IsSuccess()); + + Robotico.Result.Result> queried = _session.QueryEvents(tableId, 0, 2000, 10); + + Assert.True(queried.IsSuccess(out IReadOnlyList? 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 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> result = _session.LookupInverted(tableId, 0, "/api/foo", 10); + + Assert.True(result.IsSuccess(out IReadOnlyList? 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> result = _session.SearchFullText(tableId, 0, "world", 10); + + Assert.True(result.IsSuccess(out IReadOnlyList? 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 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 result = _session.RegisterEventTable(schema); + Assert.True(result.IsSuccess(out ulong id, out _)); + return id; + } +} diff --git a/tests/Robotico.Nexus.Client.Tests/Time/NexusTimeStoreTests.cs b/tests/Robotico.Nexus.Client.Tests/Time/NexusTimeStoreTests.cs new file mode 100644 index 0000000..f3b74a5 --- /dev/null +++ b/tests/Robotico.Nexus.Client.Tests/Time/NexusTimeStoreTests.cs @@ -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 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 result = _client.Time.Open("mytimedb"); + + Assert.True(result.IsSuccess(out ITimeSession? session, out _)); + session!.Dispose(); + } + + [Fact] + public void Open_UsesDefaultDatabaseName_WhenPathBlank() + { + Robotico.Result.Result 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 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 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 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 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 result = _client.Time.Create(FakeNexusTriggers.PingReportsFailure); + + Assert.True(result.IsError(out IError? error)); + Assert.Equal(NexusErrors.Unavailable, error!.Code); + } +} diff --git a/tests/Robotico.Nexus.Client.Tests/Time/TimeWireMappingTests.cs b/tests/Robotico.Nexus.Client.Tests/Time/TimeWireMappingTests.cs new file mode 100644 index 0000000..b2d6dae --- /dev/null +++ b/tests/Robotico.Nexus.Client.Tests/Time/TimeWireMappingTests.cs @@ -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)); + } +} diff --git a/tests/coverlet.runsettings b/tests/coverlet.runsettings new file mode 100644 index 0000000..1accbbf --- /dev/null +++ b/tests/coverlet.runsettings @@ -0,0 +1,14 @@ + + + + + + + cobertura + [Robotico.Nexus.Client]* + [Robotico.Result]*,[Robotico.Graph]*,[Robotico.Time]*,[Mishima.Nexus.Wire.V1]*,[xunit.*]*,[coverlet.*]* + + + + +