commit cbae4d1aa2fec9b8bcae858be3987439d71998e7 Author: Sergej Kern Date: Sat Jul 25 03:44:53 2026 +0200 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 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.*]* + + + + +