Compare commits

..

No commits in common. "main" and "v1.0.0" have entirely different histories.
main ... v1.0.0

15 changed files with 8 additions and 367 deletions

View File

@ -1,14 +1,5 @@
# Changelog
## Unreleased
## 1.0.1
- `NexusTimeSession` catalog/ops extras (ISP: not on `ITimeSession`): `ListSeries`,
`GetCardinality`, `ApplyRetention` over Wire.V1 RPCs with DTOs
`TimeSeriesCatalogEntry`, `TimeCardinalitySnapshot`, `TimeMetricCardinality`.
- Suite-layout ProjectReference resolution for `Mishima.Nexus.Wire.V1` (alongside sibling monorepo path).
## 1.0.0
- Initial `NexusClient`: connects via `nexus+graph|time|document|relational://` URIs (explicit port

View File

@ -11,7 +11,7 @@
</PropertyGroup>
<PropertyGroup>
<Version>1.0.1</Version>
<Version>1.0.0</Version>
<Product>Robotico.Nexus.Client</Product>
<PackageId>Robotico.Nexus.Client</PackageId>
<Title>Robotico.Nexus.Client</Title>
@ -39,11 +39,6 @@
<PropertyGroup>
<RoboticoGraphHubProjectPath>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\robotico-graph-csharp\src\Robotico.Graph\Robotico.Graph.csproj'))</RoboticoGraphHubProjectPath>
<RoboticoTimeHubProjectPath>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\robotico-time-csharp\src\Robotico.Time\Robotico.Time.csproj'))</RoboticoTimeHubProjectPath>
<!-- Suite layout: robotico-patterns lives under mishima-suite/ -->
<_MishimaNexusWireV1Suite>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\..\mishima-nexus\src\Mishima.Nexus.Wire.V1\Mishima.Nexus.Wire.V1.csproj'))</_MishimaNexusWireV1Suite>
<!-- Sibling layout: robotico-patterns next to mishima-suite/ -->
<_MishimaNexusWireV1Sibling>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\..\mishima-suite\mishima-nexus\src\Mishima.Nexus.Wire.V1\Mishima.Nexus.Wire.V1.csproj'))</_MishimaNexusWireV1Sibling>
<MishimaNexusWireV1ProjectPath Condition="Exists('$(_MishimaNexusWireV1Suite)')">$(_MishimaNexusWireV1Suite)</MishimaNexusWireV1ProjectPath>
<MishimaNexusWireV1ProjectPath Condition="'$(MishimaNexusWireV1ProjectPath)' == '' and Exists('$(_MishimaNexusWireV1Sibling)')">$(_MishimaNexusWireV1Sibling)</MishimaNexusWireV1ProjectPath>
<MishimaNexusWireV1ProjectPath>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\..\mishima-suite\mishima-nexus\src\Mishima.Nexus.Wire.V1\Mishima.Nexus.Wire.V1.csproj'))</MishimaNexusWireV1ProjectPath>
</PropertyGroup>
</Project>

View File

@ -7,7 +7,7 @@
<PackageVersion Include="coverlet.collector" Version="10.0.1" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="10.0.301" />
<PackageVersion Include="Robotico.Result" Version="1.0.4" />
<PackageVersion Include="Robotico.Result" Version="1.0.3" />
<PackageVersion Include="Robotico.Graph" Version="1.0.0" />
<PackageVersion Include="Robotico.Time" Version="1.0.0" />
<PackageVersion Include="Mishima.Nexus.Wire.V1" Version="0.4.9" />

View File

@ -1,54 +1,16 @@
[![Woodpecker CI](https://dvalin.robotico.dev/api/badges/mishima/mishima-nexus-client-csharp/status.svg)](https://dvalin.robotico.dev/mishima/mishima-nexus-client-csharp)
# Robotico.Nexus.Client
[![.NET 10](https://img.shields.io/badge/.NET-10.0-512BD4?logo=dotnet)](https://dotnet.microsoft.com/download/dotnet/10.0)
[![C#](https://img.shields.io/badge/C%23-latest-239120?logo=csharp)](https://learn.microsoft.com/dotnet/csharp/)
[![gRPC](https://img.shields.io/badge/gRPC-Nexus%20Wire-5C4EE5?logo=grpc)](https://grpc.io/)
[![NuGet](https://img.shields.io/badge/NuGet-Robotico.Nexus.Client-blue?logo=nuget)](https://download.robotico.dev/nuget/)
[![Mishima](https://img.shields.io/badge/Mishima-Nexus%20client-1f883d?logo=github)](https://brokkr.robotico.dev/mishima/mishima-nexus-client-csharp)
# mishima-nexus-client-csharp
C# client SDK for a remote [Mishima Nexus](https://brokkr.robotico.dev/mishima/mishima-nexus)
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/mishima/mishima-nexus-client-csharp`
- 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`.
<!-- repo-uses:start -->
## Uses
This repository uses:
```mermaid
flowchart LR
mishima_nexus_client_csharp["mishima-nexus-client-csharp"]
mishima_nexus["mishima-nexus"]
mishima_nexus_client_csharp --> mishima_nexus
```
<!-- repo-uses:end -->
<!-- repo-used-by:start -->
## Used by
This repository is used by:
```mermaid
flowchart LR
mishima_nexus_client_csharp["mishima-nexus-client-csharp"]
mishima_nexus["mishima-nexus"]
mishima_nexus_wpf["mishima-nexus-wpf"]
tanukiarmy_bot_ng["tanukiarmy-bot-ng"]
mishima_nexus_client_csharp --> mishima_nexus
mishima_nexus_client_csharp --> mishima_nexus_wpf
mishima_nexus_client_csharp --> tanukiarmy_bot_ng
```
<!-- repo-used-by:end -->
## Quick start
```csharp

View File

@ -9,7 +9,6 @@
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<WarningLevel>8</WarningLevel>
<WarningLevel Condition="'$(MSBuildProjectExtension)' == '.fsproj'">5</WarningLevel>
<AnalysisLevel>latest-all</AnalysisLevel>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<GenerateDocumentationFile>true</GenerateDocumentationFile>

View File

@ -53,29 +53,6 @@ public sealed class NexusDocumentClient
return NexusWireStatus.ToResult(response.Status, body);
});
/// <summary>
/// Reads many documents in one RPC. Missing ids are omitted from the list.
/// </summary>
public Robotico.Result.Result<IReadOnlyList<NexusRetrievedDocument>> GetDocuments(
string collectionName,
IReadOnlyList<string> documentIds) => InvokeGeneric(() =>
{
ArgumentNullException.ThrowIfNull(documentIds);
DocumentCollectionGetManyRequest request = new() { CollectionName = collectionName };
request.DocumentIds.AddRange(documentIds);
DocumentCollectionGetManyResponse response = _client.CollectionGetMany(request, Metadata());
List<NexusRetrievedDocument> found = new(response.Documents.Count);
foreach (DocumentCollectionDocumentWire doc in response.Documents)
{
string body = doc.JsonUtf8.Length > 0
? System.Text.Encoding.UTF8.GetString(doc.JsonUtf8.Span)
: doc.JsonBody;
found.Add(new NexusRetrievedDocument(doc.DocumentId, body));
}
return NexusWireStatus.ToResult(response.Status, (IReadOnlyList<NexusRetrievedDocument>)found);
});
/// <summary>Deletes a document by id when present (idempotent).</summary>
public Robotico.Result.Result DeleteDocument(string collectionName, string documentId) => Invoke(() =>
{

View File

@ -1,4 +0,0 @@
namespace Robotico.Nexus.Client.Document;
/// <summary>One document returned by <see cref="NexusDocumentClient.GetDocuments"/>.</summary>
public readonly record struct NexusRetrievedDocument(string DocumentId, string JsonBody);

View File

@ -322,108 +322,6 @@ public sealed class NexusTimeSession : ITimeSession
});
}
/// <summary>
/// Lists registered series for explorers (Nexus wire extra; not on <see cref="ITimeSession"/>).
/// </summary>
public Robotico.Result.Result<IReadOnlyList<TimeSeriesCatalogEntry>> ListSeries(int limit = 1000)
{
Robotico.Result.Result disposed = EnsureNotDisposed();
if (disposed.IsError(out IError? disposedError))
{
return Robotico.Result.Result.Error<IReadOnlyList<TimeSeriesCatalogEntry>>(disposedError!);
}
int normalizedLimit = limit < 1 ? 1000 : limit;
return InvokeGeneric(() =>
{
TimeListSeriesResponse response = _client.ListSeries(
new TimeListSeriesRequest { Limit = normalizedLimit },
Metadata());
TimeSeriesCatalogEntry[] entries = new TimeSeriesCatalogEntry[response.Series.Count];
for (int i = 0; i < response.Series.Count; i++)
{
TimeSeriesCatalogEntryWire wire = response.Series[i];
Dictionary<string, string> labels = new(StringComparer.Ordinal);
foreach (TimeLabelPair pair in wire.Labels)
{
labels[pair.Key] = pair.Value;
}
entries[i] = new TimeSeriesCatalogEntry(
wire.SeriesId,
wire.MetricName,
labels,
wire.SampleCount);
}
return NexusWireStatus.ToResult(response.Status, (IReadOnlyList<TimeSeriesCatalogEntry>)entries);
});
}
/// <summary>
/// Cardinality inspection (Nexus wire extra; not on <see cref="ITimeSession"/>).
/// </summary>
public Robotico.Result.Result<TimeCardinalitySnapshot> GetCardinality(int topN = 20)
{
Robotico.Result.Result disposed = EnsureNotDisposed();
if (disposed.IsError(out IError? disposedError))
{
return Robotico.Result.Result.Error<TimeCardinalitySnapshot>(disposedError!);
}
int normalizedTopN = topN < 1 ? 20 : topN;
return InvokeGeneric(() =>
{
TimeGetCardinalityResponse response = _client.GetCardinality(
new TimeGetCardinalityRequest { TopN = normalizedTopN },
Metadata());
TimeMetricCardinality[] top =
[
.. response.TopMetricsBySeriesCount.Select(static m =>
new TimeMetricCardinality(m.MetricName, m.SeriesCount)),
];
TimeCardinalitySnapshot snap = new(
response.ActiveSeriesCount,
response.TotalSampleCount,
top);
return NexusWireStatus.ToResult(response.Status, snap);
});
}
/// <summary>
/// Applies retention cutoff (Nexus wire extra; not on <see cref="ITimeSession"/>).
/// Drops samples/events with timestamp strictly before
/// <paramref name="retainAfterExclusiveUnixNanos"/>.
/// </summary>
/// <returns>Removed sample/event count reported by the engine.</returns>
public Robotico.Result.Result<long> ApplyRetention(long retainAfterExclusiveUnixNanos)
{
Robotico.Result.Result disposed = EnsureNotDisposed();
if (disposed.IsError(out IError? disposedError))
{
return Robotico.Result.Result.Error<long>(disposedError!);
}
if (retainAfterExclusiveUnixNanos <= 0)
{
return Robotico.Result.Result.Error<long>(
TimeErrors.Error(
"retainAfterExclusiveUnixNanos must be positive.",
TimeErrors.InvalidArgument));
}
return InvokeGeneric(() =>
{
TimeApplyRetentionResponse response = _client.ApplyRetention(
new TimeApplyRetentionRequest
{
RetainAfterExclusiveUnixNanos = retainAfterExclusiveUnixNanos,
},
Metadata());
return NexusWireStatus.ToResult(response.Status, response.RemovedCount);
});
}
/// <inheritdoc />
public void Dispose() => _disposed = true;

View File

@ -1,13 +0,0 @@
namespace Robotico.Nexus.Client.Time;
/// <summary>
/// Cardinality snapshot from <see cref="NexusTimeSession.GetCardinality"/>.
/// Not on <c>ITimeSession</c> (ISP): ops inspection surface for explorers.
/// </summary>
/// <param name="ActiveSeriesCount">Active series in the logical database.</param>
/// <param name="TotalSampleCount">Total samples across all series.</param>
/// <param name="TopMetricsBySeriesCount">Top metrics by series fan-out.</param>
public sealed record TimeCardinalitySnapshot(
long ActiveSeriesCount,
long TotalSampleCount,
IReadOnlyList<TimeMetricCardinality> TopMetricsBySeriesCount);

View File

@ -1,6 +0,0 @@
namespace Robotico.Nexus.Client.Time;
/// <summary>Per-metric series count for cardinality inspection.</summary>
/// <param name="MetricName">Metric name.</param>
/// <param name="SeriesCount">Number of series with this metric name.</param>
public sealed record TimeMetricCardinality(string MetricName, long SeriesCount);

View File

@ -1,15 +0,0 @@
namespace Robotico.Nexus.Client.Time;
/// <summary>
/// One registered series from <see cref="NexusTimeSession.ListSeries"/>.
/// Not on <c>ITimeSession</c> (ISP): catalog ops are Nexus wire extras for explorers/ops.
/// </summary>
/// <param name="SeriesId">Engine-assigned series id.</param>
/// <param name="MetricName">Metric name (Prometheus-style).</param>
/// <param name="Labels">Series labels (sorted as returned by the engine).</param>
/// <param name="SampleCount">Current sample count for the series.</param>
public sealed record TimeSeriesCatalogEntry(
ulong SeriesId,
string MetricName,
IReadOnlyDictionary<string, string> Labels,
int SampleCount);

View File

@ -48,22 +48,6 @@ public class NexusDocumentClientTests : IAsyncLifetime
Assert.Equal("""{"name":"Ada"}""", body);
}
[Fact]
public void GetDocuments_ReturnsFound_AndOmitsMissing()
{
_documentClient.UpsertDocument("users", "u1", """{"name":"Ada"}""");
_documentClient.UpsertDocument("users", "u3", """{"name":"Grace"}""");
Robotico.Result.Result<IReadOnlyList<NexusRetrievedDocument>> fetched =
_documentClient.GetDocuments("users", ["u3", "missing", "u1"]);
Assert.True(fetched.IsSuccess(out IReadOnlyList<NexusRetrievedDocument>? docs, out _));
Assert.Equal(2, docs!.Count);
Assert.Equal("u3", docs[0].DocumentId);
Assert.Equal("""{"name":"Grace"}""", docs[0].JsonBody);
Assert.Equal("u1", docs[1].DocumentId);
}
[Fact]
public void GetDocument_ReturnsNull_WhenNotFound()
{

View File

@ -4,8 +4,8 @@ using Mishima.Nexus.Wire.V1;
namespace Robotico.Nexus.Client.Tests.TestSupport;
/// <summary>
/// In-memory fake standing in for a real Nexus document engine host — the RPCs used by
/// <see cref="Robotico.Nexus.Client.Document.NexusDocumentClient"/> (Ping, CRUD, GetMany).
/// In-memory fake standing in for a real Nexus document engine host — only the RPCs used by
/// <see cref="Robotico.Nexus.Client.Document.NexusDocumentClient"/> (Ping + single-document CRUD).
/// </summary>
public sealed class FakeDocumentEngineService : NexusDocumentEngineService.NexusDocumentEngineServiceBase
{
@ -36,29 +36,6 @@ public sealed class FakeDocumentEngineService : NexusDocumentEngineService.Nexus
return Task.FromResult(new DocumentGetResponse { Status = NexusStatuses.Ok, Found = false });
}
/// <inheritdoc />
public override Task<DocumentCollectionGetManyResponse> CollectionGetMany(
DocumentCollectionGetManyRequest request,
ServerCallContext context)
{
DocumentCollectionGetManyResponse response = new() { Status = NexusStatuses.Ok };
foreach (string id in request.DocumentIds)
{
if (!_documents.TryGetValue((request.CollectionName, id), out string? body))
{
continue;
}
response.Documents.Add(new DocumentCollectionDocumentWire
{
DocumentId = id,
JsonBody = body,
});
}
return Task.FromResult(response);
}
/// <inheritdoc />
public override Task<DocumentDeleteResponse> DeleteDocument(DocumentDeleteRequest request, ServerCallContext context)
{

View File

@ -7,13 +7,11 @@ namespace Robotico.Nexus.Client.Tests.TestSupport;
public sealed class FakeTimeEngineService : NexusTimeEngineService.NexusTimeEngineServiceBase
{
private readonly Dictionary<ulong, List<TimeSampleWire>> _samplesBySeries = [];
private readonly Dictionary<ulong, (string MetricName, IReadOnlyList<TimeLabelPair> Labels)> _seriesMeta = [];
private readonly Dictionary<ulong, List<TimeWideEventWire>> _eventsByTable = [];
private readonly Dictionary<ulong, List<TimeEventFieldWire>> _fieldsByTable = [];
private ulong _nextSeriesId = 1;
private ulong _nextTableId = 1;
private ulong _nextEventId = 1;
private long _retentionRemoved;
/// <inheritdoc />
public override Task<TimePingResponse> Ping(TimePingRequest request, ServerCallContext context)
@ -42,76 +40,9 @@ public sealed class FakeTimeEngineService : NexusTimeEngineService.NexusTimeEngi
{
ulong id = _nextSeriesId++;
_samplesBySeries[id] = [];
_seriesMeta[id] = (request.MetricName, [.. request.Labels]);
return Task.FromResult(new TimeRegisterSeriesResponse { Status = NexusStatuses.Ok, SeriesId = id });
}
/// <inheritdoc />
public override Task<TimeListSeriesResponse> ListSeries(TimeListSeriesRequest request, ServerCallContext context)
{
TimeListSeriesResponse response = new() { Status = NexusStatuses.Ok };
int limit = request.Limit is 0 ? int.MaxValue : request.Limit;
foreach ((ulong id, (string metric, IReadOnlyList<TimeLabelPair> labels)) in _seriesMeta.Take(limit))
{
TimeSeriesCatalogEntryWire entry = new()
{
SeriesId = id,
MetricName = metric,
SampleCount = _samplesBySeries.TryGetValue(id, out List<TimeSampleWire>? samples) ? samples.Count : 0,
};
entry.Labels.AddRange(labels);
response.Series.Add(entry);
}
return Task.FromResult(response);
}
/// <inheritdoc />
public override Task<TimeGetCardinalityResponse> GetCardinality(TimeGetCardinalityRequest request, ServerCallContext context)
{
Dictionary<string, int> byMetric = new(StringComparer.Ordinal);
foreach ((string metric, _) in _seriesMeta.Values)
{
byMetric[metric] = byMetric.GetValueOrDefault(metric) + 1;
}
int topN = request.TopN is 0 ? 20 : request.TopN;
TimeGetCardinalityResponse response = new()
{
Status = NexusStatuses.Ok,
ActiveSeriesCount = _seriesMeta.Count,
TotalSampleCount = _samplesBySeries.Values.Sum(list => list.Count),
};
foreach ((string metric, int count) in byMetric.OrderByDescending(static p => p.Value).ThenBy(static p => p.Key, StringComparer.Ordinal).Take(topN))
{
response.TopMetricsBySeriesCount.Add(new TimeMetricCardinalityWire { MetricName = metric, SeriesCount = count });
}
return Task.FromResult(response);
}
/// <inheritdoc />
public override Task<TimeApplyRetentionResponse> ApplyRetention(TimeApplyRetentionRequest request, ServerCallContext context)
{
long removed = 0;
foreach (List<TimeSampleWire> samples in _samplesBySeries.Values)
{
int before = samples.Count;
samples.RemoveAll(s => s.TimestampUnixNanos < request.RetainAfterExclusiveUnixNanos);
removed += before - samples.Count;
}
foreach (List<TimeWideEventWire> events in _eventsByTable.Values)
{
int before = events.Count;
events.RemoveAll(e => e.TimestampUnixNanos < request.RetainAfterExclusiveUnixNanos);
removed += before - events.Count;
}
_retentionRemoved = removed;
return Task.FromResult(new TimeApplyRetentionResponse { Status = NexusStatuses.Ok, RemovedCount = removed });
}
/// <inheritdoc />
public override Task<TimeAppendSampleResponse> AppendSample(TimeAppendSampleRequest request, ServerCallContext context)
{

View File

@ -1,5 +1,4 @@
using Robotico.Nexus.Client.Tests.TestSupport;
using Robotico.Nexus.Client.Time;
using Robotico.Result.Errors;
using Robotico.Time;
using Xunit;
@ -231,36 +230,9 @@ public class NexusTimeSessionTests : IAsyncLifetime
Assert.Single(hits!);
}
[Fact]
public void ListSeries_GetCardinality_ApplyRetention_CatalogOps()
{
NexusTimeSession nexus = Assert.IsType<NexusTimeSession>(_session);
ulong s1 = RegisterSeries("cpu.load");
_session.AppendSample(s1, 100, 1.0);
_session.AppendSample(s1, 200, 2.0);
ulong s2 = RegisterSeries("cpu.load");
RegisterSeries("mem.used");
Assert.True(nexus.ListSeries(10).IsSuccess(out IReadOnlyList<TimeSeriesCatalogEntry>? listed, out _));
Assert.Equal(3, listed!.Count);
Assert.Contains(listed, e => e is { SeriesId: var id, MetricName: "cpu.load", SampleCount: 2 } && id == s1);
Assert.True(nexus.GetCardinality(10).IsSuccess(out TimeCardinalitySnapshot? card, out _));
Assert.Equal(3, card!.ActiveSeriesCount);
Assert.Equal(2, card.TotalSampleCount);
Assert.Contains(card.TopMetricsBySeriesCount, m => m is { MetricName: "cpu.load", SeriesCount: 2 });
Assert.True(nexus.ApplyRetention(150).IsSuccess(out long removed, out _));
Assert.True(removed >= 1);
Assert.True(nexus.ApplyRetention(0).IsError(out IError? retErr));
Assert.Equal(TimeErrors.InvalidArgument, retErr!.Code);
}
[Fact]
public void AllOperations_Fail_AfterDispose()
{
NexusTimeSession nexus = Assert.IsType<NexusTimeSession>(_session);
_session.Dispose();
Assert.True(_session.Doctor().IsError(out IError? e1));
@ -295,13 +267,6 @@ public class NexusTimeSessionTests : IAsyncLifetime
Assert.True(_session.SearchFullText(1, 0, "x", 10).IsError(out IError? e11));
Assert.Equal(NexusErrors.Disposed, e11!.Code);
Assert.True(nexus.ListSeries().IsError(out IError? e12));
Assert.Equal(NexusErrors.Disposed, e12!.Code);
Assert.True(nexus.GetCardinality().IsError(out IError? e13));
Assert.Equal(NexusErrors.Disposed, e13!.Code);
Assert.True(nexus.ApplyRetention(1).IsError(out IError? e14));
Assert.Equal(NexusErrors.Disposed, e14!.Code);
}
private ulong RegisterSeries(string metricName)