Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions Trax.Samples.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@
<Folder Name="/samples/ApiAudit/">
<Project Path="samples/ApiAudit/Trax.Samples.ApiAudit/Trax.Samples.ApiAudit.csproj" />
</Folder>
<Folder Name="/samples/PersistedOperations/">
<Project Path="samples/PersistedOperations/Trax.Samples.PersistedOperations/Trax.Samples.PersistedOperations.csproj" />
<Project Path="samples/PersistedOperations/Trax.Samples.PersistedOperations.Api/Trax.Samples.PersistedOperations.Api.csproj" />
<Project Path="samples/PersistedOperations/Trax.Samples.PersistedOperations.Client/Trax.Samples.PersistedOperations.Client.csproj" />
</Folder>
<Folder Name="/samples/TestRunner/">
<Project Path="samples/TestRunner/Trax.Samples.TestRunner/Trax.Samples.TestRunner.csproj" />
<Project Path="samples/TestRunner/Trax.Samples.TestRunner.Hub/Trax.Samples.TestRunner.Hub.csproj" />
Expand All @@ -46,6 +51,7 @@
<Project Path="tests/Trax.Samples.EnergyHub.E2E/Trax.Samples.EnergyHub.E2E.csproj" />
<Project Path="tests/Trax.Samples.JobHunt.Tests/Trax.Samples.JobHunt.Tests.csproj" />
<Project Path="tests/Trax.Samples.JobHunt.E2E/Trax.Samples.JobHunt.E2E.csproj" />
<Project Path="tests/Trax.Samples.PersistedOperations.E2E/Trax.Samples.PersistedOperations.E2E.csproj" />
</Folder>
<Folder Name="/templates/">
<Project Path="templates/Trax.Samples.Templates.csproj" />
Expand Down
4 changes: 2 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ services:
image: rabbitmq:4-management
restart: always
environment:
RABBITMQ_DEFAULT_USER: guest
RABBITMQ_DEFAULT_PASS: guest
RABBITMQ_DEFAULT_USER: trax
RABBITMQ_DEFAULT_PASS: trax123
ports:
- "5672:5672"
- "15672:15672"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// ─────────────────────────────────────────────────────────────────────────────
// Trax Persisted Operations sample — GraphQL API
//
// This sample demonstrates Trax.Api.GraphQL.PersistedOperations end-to-end:
//
// - Real trains (GreetTrain, LookupUserTrain) registered through AddMediator
// and exposed via [TraxQuery] on the GraphQL schema.
// - The API only accepts persisted operations: clients send `id` and the
// server resolves to the stored document via IOperationDocumentStorage.
// - Operators can hot-fix a persisted document (or the underlying junction
// code) without redeploying clients, as long as the response shape stays
// compatible. The shape-diff guardrail in IPersistedOperationStore
// enforces this contract on every edit.
//
// Run alongside the Client project to see the upload + query + hot-fix loop.
// ─────────────────────────────────────────────────────────────────────────────

using Trax.Api.GraphQL.Extensions;
using Trax.Api.GraphQL.PersistedOperations.Extensions;
using Trax.Effect.Data.Postgres.Extensions;
using Trax.Effect.Extensions;
using Trax.Mediator.Extensions;
using Trax.Samples.PersistedOperations;

var builder = WebApplication.CreateBuilder(args);

var connectionString =
builder.Configuration.GetConnectionString("TraxDatabase")
?? "Host=localhost;Port=5432;Database=trax;Username=trax;Password=trax123";

builder.Services.AddAuthorization();

// Trax: Postgres effects + mediator (which discovers trains in the library
// assembly). No scheduler — every train in this sample is a query handled
// directly on the API server via GraphQL.
builder.Services.AddTrax(trax =>
trax.AddEffects(effects => effects.UsePostgres(connectionString))
.AddMediator(typeof(GraphQLNamespaces).Assembly)
);

// GraphQL schema with persisted-operations enforcement enabled. AddTraxGraphQL
// auto-discovers [TraxQuery]-attributed trains from the registered mediator
// assemblies and exposes them under their declared namespace.
builder.Services.AddTraxGraphQL(graphql =>
graphql.UsePersistedOperations(opts =>
opts.UseDatabase(connectionString)
.RequirePersisted(true)
.LogNonPersistedRequests(true)
// Dev-prefixed operations bypass enforcement so developers can
// iterate on a query during development without round-tripping
// through the manifest uploader.
.AllowOperationsMatching(id => id.StartsWith("dev_"))
)
);

var app = builder.Build();

// Persisted-op enforcement runs before the GraphQL endpoint. In a real
// deployment, ASP.NET authentication middleware sits in front of this and
// the persisted-op lookup happens AFTER Trax's auth interceptor inside the
// HC pipeline.
app.UsePersistedOperationsEnforcement();
app.UseRouting();
app.UseTraxGraphQL();

app.Run();

/// <summary>
/// Visible to <c>WebApplicationFactory&lt;Program&gt;</c> for E2E tests.
/// </summary>
public partial class Program;
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowMissingPrunePackageData>true</AllowMissingPrunePackageData>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\Trax.Samples.PersistedOperations\Trax.Samples.PersistedOperations.csproj" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Trax.Api" Version="1.*" />
<PackageReference Include="Trax.Api.GraphQL" Version="1.*" />
<PackageReference Include="Trax.Api.GraphQL.PersistedOperations" Version="1.*" />
<PackageReference Include="Trax.Effect.Provider.Json" Version="1.*" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"ConnectionStrings": {
"TraxDatabase": "Host=localhost;Port=5432;Database=trax;Username=trax;Password=trax123"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// ─────────────────────────────────────────────────────────────────────────────
// Trax Persisted Operations sample — Client
//
// 1. Connects to the same Trax Postgres as the API process.
// 2. Uploads the manifest of (id, document) pairs through IPersistedOperationStore.
// 3. Sends GraphQL requests by id only — the server resolves to the stored
// document and dispatches the call to the underlying train.
// 4. Demonstrates the hot-fix flow by re-uploading a shape-preserving
// edit; the next request runs the new document without redeploying the
// client.
//
// Run after starting Trax.Samples.PersistedOperations.Api.
// ─────────────────────────────────────────────────────────────────────────────

using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Trax.Api.GraphQL.PersistedOperations.Extensions;
using Trax.Api.GraphQL.PersistedOperations.Storage;
using Trax.Effect.Data.Postgres.Extensions;
using Trax.Effect.Extensions;

const string ConnectionString =
"Host=localhost;Port=5432;Database=trax;Username=trax;Password=trax123";
const string ApiUrl = "http://localhost:5000/trax/graphql/";

await using var services = new ServiceCollection()
.AddLogging(b => b.AddSimpleConsole())
.AddTrax(trax => trax.AddEffects(effects => effects.UsePostgres(ConnectionString)))
.AddPersistedOperationStore(ConnectionString)
.BuildServiceProvider();

var store = services.GetRequiredService<IPersistedOperationStore>();

// 1. Upload the manifest.
foreach (var op in LoadManifest())
{
await store.UpsertAsync(op.Id, op.Document, options: null, CancellationToken.None);
Console.WriteLine($"Uploaded {op.Id}");
}

// 2. Call greet_v1 by id.
using var http = new HttpClient { BaseAddress = new Uri(ApiUrl) };
Console.WriteLine("\n--- greet_v1 (Alice) ---");
Console.WriteLine(await PostByIdAsync(http, "greet_v1", new { input = new { name = "Alice" } }));

// 3. Call lookupUser_v1 by id.
Console.WriteLine("\n--- lookupUser_v1 (user-42) ---");
Console.WriteLine(
await PostByIdAsync(http, "lookupUser_v1", new { input = new { userId = "user-42" } })
);

// 4. Hot-fix demo: rewrite greet_v1 with a shape-preserving change. In a
// real fix you might swap to a different train or change a filter; here
// we change a static argument to demonstrate that the server-side change
// flows through without touching the client. The shape-diff guardrail
// permits this because the response shape is unchanged.
await store.UpsertAsync(
"greet_v1",
"query Greet($input: GreetInput!) { discover { greeting { greet(input: $input) { greeting greetedAt } } } }",
options: new UpsertOptions { Description = "demo hot-fix (shape-preserving rewrite)" },
CancellationToken.None
);
Console.WriteLine("\nHot-fixed greet_v1 (no client redeploy needed).");

Console.WriteLine("\n--- greet_v1 after hot-fix (Alice) ---");
Console.WriteLine(await PostByIdAsync(http, "greet_v1", new { input = new { name = "Alice" } }));

static async Task<string> PostByIdAsync(HttpClient http, string id, object variables)
{
var body = new { id, variables };
var resp = await http.PostAsJsonAsync(string.Empty, body);
return await resp.Content.ReadAsStringAsync();
}

static IEnumerable<ManifestEntry> LoadManifest()
{
var path = Path.Combine(AppContext.BaseDirectory, "manifest.json");
using var stream = File.OpenRead(path);
var doc = JsonDocument.Parse(stream);
foreach (var entry in doc.RootElement.GetProperty("operations").EnumerateArray())
{
yield return new ManifestEntry(
entry.GetProperty("id").GetString()!,
entry.GetProperty("document").GetString()!
);
}
}

internal sealed record ManifestEntry(string Id, string Document);
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowMissingPrunePackageData>true</AllowMissingPrunePackageData>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.*" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.*" />
<PackageReference Include="Trax.Api.GraphQL.PersistedOperations" Version="1.*" />
<PackageReference Include="Trax.Effect.Data.Postgres" Version="1.*" />
</ItemGroup>

<ItemGroup>
<None Update="manifest.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"operations": [
{
"id": "greet_v1",
"document": "query Greet($input: GreetInput!) { discover { greeting { greet(input: $input) { greeting greetedAt } } } }"
},
{
"id": "lookupUser_v1",
"document": "query LookupUser($input: LookupUserInput!) { discover { users { lookupUser(input: $input) { userId displayName email loginCount } } } }"
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace Trax.Samples.PersistedOperations;

/// <summary>
/// GraphQL namespace constants. Trains sharing a namespace are grouped
/// under the same sub-field in the schema (e.g.
/// <c>discover { greeting { greet(...) { ... } } }</c>).
/// </summary>
public static class GraphQLNamespaces
{
public const string Greeting = "greeting";
public const string Users = "users";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using Trax.Effect.Models.Manifest;

namespace Trax.Samples.PersistedOperations.Trains.Greeting.Greet;

/// <summary>
/// Input for the greet train: the name to address in the response.
/// </summary>
public record GreetInput : IManifestProperties
{
public required string Name { get; init; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace Trax.Samples.PersistedOperations.Trains.Greeting.Greet;

/// <summary>
/// Output from the greet train.
/// </summary>
public record GreetOutput
{
public required string Greeting { get; init; }
public required DateTimeOffset GreetedAt { get; init; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using LanguageExt;
using Trax.Effect.Attributes;
using Trax.Effect.Services.ServiceTrain;
using Trax.Samples.PersistedOperations.Trains.Greeting.Greet.Junctions;

namespace Trax.Samples.PersistedOperations.Trains.Greeting.Greet;

/// <summary>
/// Trivial query train that produces a greeting for a name. Exposed as
/// <c>discover { greeting { greet(input: { name: ... }) { ... } } }</c>.
/// The persisted-operation manifest binds an id (e.g. <c>greet_v1</c>) to
/// a GraphQL document that calls this train.
/// </summary>
[TraxQuery(Namespace = GraphQLNamespaces.Greeting, Description = "Builds a greeting for a name")]
public class GreetTrain : ServiceTrain<GreetInput, GreetOutput>, IGreetTrain
{
protected override Task<Either<Exception, GreetOutput>> Junctions() =>
Chain<ComposeGreetingJunction>().Resolve();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
using Trax.Effect.Services.ServiceTrain;

namespace Trax.Samples.PersistedOperations.Trains.Greeting.Greet;

public interface IGreetTrain : IServiceTrain<GreetInput, GreetOutput>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using Trax.Core.Junction;

namespace Trax.Samples.PersistedOperations.Trains.Greeting.Greet.Junctions;

/// <summary>
/// Builds a greeting string for the supplied name. The wording lives in
/// this junction so that a hot-fix to the persisted document (or to this
/// junction) lets you change the greeting without redeploying clients.
/// </summary>
public class ComposeGreetingJunction : Junction<GreetInput, GreetOutput>
{
public override Task<GreetOutput> Run(GreetInput input)
{
var output = new GreetOutput
{
Greeting = $"Hello, {input.Name}.",
GreetedAt = DateTimeOffset.UtcNow,
};
return Task.FromResult(output);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
using Trax.Effect.Services.ServiceTrain;

namespace Trax.Samples.PersistedOperations.Trains.Users.LookupUser;

public interface ILookupUserTrain : IServiceTrain<LookupUserInput, UserProfile>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using Microsoft.Extensions.Logging;
using Trax.Core.Junction;

namespace Trax.Samples.PersistedOperations.Trains.Users.LookupUser.Junctions;

/// <summary>
/// Resolves a user profile. In a real app this would be an EF Core query;
/// the sample returns a deterministic fixture so the persisted-op flow is
/// exercised without seed data.
/// </summary>
public class FetchUserJunction(ILogger<FetchUserJunction> logger)
: Junction<LookupUserInput, UserProfile>
{
public override Task<UserProfile> Run(LookupUserInput input)
{
logger.LogInformation("Looking up user {UserId}", input.UserId);

// Deterministic synthetic profile keyed on UserId.
var hash = (uint)input.UserId.GetHashCode();
return Task.FromResult(
new UserProfile
{
UserId = input.UserId,
DisplayName = $"User {input.UserId}",
Email = $"{input.UserId}@example.test",
LoginCount = (int)(hash % 1000),
}
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using Trax.Effect.Models.Manifest;

namespace Trax.Samples.PersistedOperations.Trains.Users.LookupUser;

public record LookupUserInput : IManifestProperties
{
public required string UserId { get; init; }
}
Loading
Loading