diff --git a/Trax.Samples.slnx b/Trax.Samples.slnx
index 3c8f1c4..6fb98fa 100644
--- a/Trax.Samples.slnx
+++ b/Trax.Samples.slnx
@@ -34,6 +34,11 @@
+
+
+
+
+
@@ -46,6 +51,7 @@
+
diff --git a/docker-compose.yml b/docker-compose.yml
index 0b2d057..dfde248 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -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"
diff --git a/samples/PersistedOperations/Trax.Samples.PersistedOperations.Api/Program.cs b/samples/PersistedOperations/Trax.Samples.PersistedOperations.Api/Program.cs
new file mode 100644
index 0000000..5c7943d
--- /dev/null
+++ b/samples/PersistedOperations/Trax.Samples.PersistedOperations.Api/Program.cs
@@ -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();
+
+///
+/// Visible to WebApplicationFactory<Program> for E2E tests.
+///
+public partial class Program;
diff --git a/samples/PersistedOperations/Trax.Samples.PersistedOperations.Api/Trax.Samples.PersistedOperations.Api.csproj b/samples/PersistedOperations/Trax.Samples.PersistedOperations.Api/Trax.Samples.PersistedOperations.Api.csproj
new file mode 100644
index 0000000..64a6d65
--- /dev/null
+++ b/samples/PersistedOperations/Trax.Samples.PersistedOperations.Api/Trax.Samples.PersistedOperations.Api.csproj
@@ -0,0 +1,20 @@
+
+
+ Exe
+ net10.0
+ enable
+ enable
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/PersistedOperations/Trax.Samples.PersistedOperations.Api/appsettings.json b/samples/PersistedOperations/Trax.Samples.PersistedOperations.Api/appsettings.json
new file mode 100644
index 0000000..2b7964a
--- /dev/null
+++ b/samples/PersistedOperations/Trax.Samples.PersistedOperations.Api/appsettings.json
@@ -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"
+ }
+ }
+}
diff --git a/samples/PersistedOperations/Trax.Samples.PersistedOperations.Client/Program.cs b/samples/PersistedOperations/Trax.Samples.PersistedOperations.Client/Program.cs
new file mode 100644
index 0000000..b74613f
--- /dev/null
+++ b/samples/PersistedOperations/Trax.Samples.PersistedOperations.Client/Program.cs
@@ -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();
+
+// 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 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 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);
diff --git a/samples/PersistedOperations/Trax.Samples.PersistedOperations.Client/Trax.Samples.PersistedOperations.Client.csproj b/samples/PersistedOperations/Trax.Samples.PersistedOperations.Client/Trax.Samples.PersistedOperations.Client.csproj
new file mode 100644
index 0000000..04383a4
--- /dev/null
+++ b/samples/PersistedOperations/Trax.Samples.PersistedOperations.Client/Trax.Samples.PersistedOperations.Client.csproj
@@ -0,0 +1,22 @@
+
+
+ Exe
+ net10.0
+ enable
+ enable
+ true
+
+
+
+
+
+
+
+
+
+
+
+ PreserveNewest
+
+
+
diff --git a/samples/PersistedOperations/Trax.Samples.PersistedOperations.Client/manifest.json b/samples/PersistedOperations/Trax.Samples.PersistedOperations.Client/manifest.json
new file mode 100644
index 0000000..d731549
--- /dev/null
+++ b/samples/PersistedOperations/Trax.Samples.PersistedOperations.Client/manifest.json
@@ -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 } } } }"
+ }
+ ]
+}
diff --git a/samples/PersistedOperations/Trax.Samples.PersistedOperations/GraphQLNamespaces.cs b/samples/PersistedOperations/Trax.Samples.PersistedOperations/GraphQLNamespaces.cs
new file mode 100644
index 0000000..0944568
--- /dev/null
+++ b/samples/PersistedOperations/Trax.Samples.PersistedOperations/GraphQLNamespaces.cs
@@ -0,0 +1,12 @@
+namespace Trax.Samples.PersistedOperations;
+
+///
+/// GraphQL namespace constants. Trains sharing a namespace are grouped
+/// under the same sub-field in the schema (e.g.
+/// discover { greeting { greet(...) { ... } } }).
+///
+public static class GraphQLNamespaces
+{
+ public const string Greeting = "greeting";
+ public const string Users = "users";
+}
diff --git a/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trains/Greeting/Greet/GreetInput.cs b/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trains/Greeting/Greet/GreetInput.cs
new file mode 100644
index 0000000..5785d85
--- /dev/null
+++ b/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trains/Greeting/Greet/GreetInput.cs
@@ -0,0 +1,11 @@
+using Trax.Effect.Models.Manifest;
+
+namespace Trax.Samples.PersistedOperations.Trains.Greeting.Greet;
+
+///
+/// Input for the greet train: the name to address in the response.
+///
+public record GreetInput : IManifestProperties
+{
+ public required string Name { get; init; }
+}
diff --git a/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trains/Greeting/Greet/GreetOutput.cs b/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trains/Greeting/Greet/GreetOutput.cs
new file mode 100644
index 0000000..6adb05a
--- /dev/null
+++ b/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trains/Greeting/Greet/GreetOutput.cs
@@ -0,0 +1,10 @@
+namespace Trax.Samples.PersistedOperations.Trains.Greeting.Greet;
+
+///
+/// Output from the greet train.
+///
+public record GreetOutput
+{
+ public required string Greeting { get; init; }
+ public required DateTimeOffset GreetedAt { get; init; }
+}
diff --git a/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trains/Greeting/Greet/GreetTrain.cs b/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trains/Greeting/Greet/GreetTrain.cs
new file mode 100644
index 0000000..728cfa9
--- /dev/null
+++ b/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trains/Greeting/Greet/GreetTrain.cs
@@ -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;
+
+///
+/// Trivial query train that produces a greeting for a name. Exposed as
+/// discover { greeting { greet(input: { name: ... }) { ... } } }.
+/// The persisted-operation manifest binds an id (e.g. greet_v1) to
+/// a GraphQL document that calls this train.
+///
+[TraxQuery(Namespace = GraphQLNamespaces.Greeting, Description = "Builds a greeting for a name")]
+public class GreetTrain : ServiceTrain, IGreetTrain
+{
+ protected override Task> Junctions() =>
+ Chain().Resolve();
+}
diff --git a/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trains/Greeting/Greet/IGreetTrain.cs b/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trains/Greeting/Greet/IGreetTrain.cs
new file mode 100644
index 0000000..00b4a9d
--- /dev/null
+++ b/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trains/Greeting/Greet/IGreetTrain.cs
@@ -0,0 +1,5 @@
+using Trax.Effect.Services.ServiceTrain;
+
+namespace Trax.Samples.PersistedOperations.Trains.Greeting.Greet;
+
+public interface IGreetTrain : IServiceTrain;
diff --git a/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trains/Greeting/Greet/Junctions/ComposeGreetingJunction.cs b/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trains/Greeting/Greet/Junctions/ComposeGreetingJunction.cs
new file mode 100644
index 0000000..b381ea8
--- /dev/null
+++ b/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trains/Greeting/Greet/Junctions/ComposeGreetingJunction.cs
@@ -0,0 +1,21 @@
+using Trax.Core.Junction;
+
+namespace Trax.Samples.PersistedOperations.Trains.Greeting.Greet.Junctions;
+
+///
+/// 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.
+///
+public class ComposeGreetingJunction : Junction
+{
+ public override Task Run(GreetInput input)
+ {
+ var output = new GreetOutput
+ {
+ Greeting = $"Hello, {input.Name}.",
+ GreetedAt = DateTimeOffset.UtcNow,
+ };
+ return Task.FromResult(output);
+ }
+}
diff --git a/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trains/Users/LookupUser/ILookupUserTrain.cs b/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trains/Users/LookupUser/ILookupUserTrain.cs
new file mode 100644
index 0000000..0e60d98
--- /dev/null
+++ b/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trains/Users/LookupUser/ILookupUserTrain.cs
@@ -0,0 +1,5 @@
+using Trax.Effect.Services.ServiceTrain;
+
+namespace Trax.Samples.PersistedOperations.Trains.Users.LookupUser;
+
+public interface ILookupUserTrain : IServiceTrain;
diff --git a/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trains/Users/LookupUser/Junctions/FetchUserJunction.cs b/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trains/Users/LookupUser/Junctions/FetchUserJunction.cs
new file mode 100644
index 0000000..a1237a2
--- /dev/null
+++ b/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trains/Users/LookupUser/Junctions/FetchUserJunction.cs
@@ -0,0 +1,30 @@
+using Microsoft.Extensions.Logging;
+using Trax.Core.Junction;
+
+namespace Trax.Samples.PersistedOperations.Trains.Users.LookupUser.Junctions;
+
+///
+/// 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.
+///
+public class FetchUserJunction(ILogger logger)
+ : Junction
+{
+ public override Task 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),
+ }
+ );
+ }
+}
diff --git a/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trains/Users/LookupUser/LookupUserInput.cs b/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trains/Users/LookupUser/LookupUserInput.cs
new file mode 100644
index 0000000..f3dbdf4
--- /dev/null
+++ b/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trains/Users/LookupUser/LookupUserInput.cs
@@ -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; }
+}
diff --git a/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trains/Users/LookupUser/LookupUserTrain.cs b/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trains/Users/LookupUser/LookupUserTrain.cs
new file mode 100644
index 0000000..685c53a
--- /dev/null
+++ b/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trains/Users/LookupUser/LookupUserTrain.cs
@@ -0,0 +1,17 @@
+using LanguageExt;
+using Trax.Effect.Attributes;
+using Trax.Effect.Services.ServiceTrain;
+using Trax.Samples.PersistedOperations.Trains.Users.LookupUser.Junctions;
+
+namespace Trax.Samples.PersistedOperations.Trains.Users.LookupUser;
+
+///
+/// Resolves a user profile by id. Exposed as
+/// discover { users { lookupUser(input: { userId: ... }) { ... } } }.
+///
+[TraxQuery(Namespace = GraphQLNamespaces.Users, Description = "Looks up a user profile by id")]
+public class LookupUserTrain : ServiceTrain, ILookupUserTrain
+{
+ protected override Task> Junctions() =>
+ Chain().Resolve();
+}
diff --git a/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trains/Users/LookupUser/UserProfile.cs b/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trains/Users/LookupUser/UserProfile.cs
new file mode 100644
index 0000000..7e6ac0c
--- /dev/null
+++ b/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trains/Users/LookupUser/UserProfile.cs
@@ -0,0 +1,14 @@
+namespace Trax.Samples.PersistedOperations.Trains.Users.LookupUser;
+
+///
+/// Lightweight user profile returned by the lookup train. Real consumers
+/// would project from a database; this sample returns a deterministic
+/// fixture so the persisted-op flow is exercised without infrastructure.
+///
+public record UserProfile
+{
+ public required string UserId { get; init; }
+ public required string DisplayName { get; init; }
+ public required string Email { get; init; }
+ public int LoginCount { get; init; }
+}
diff --git a/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trax.Samples.PersistedOperations.csproj b/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trax.Samples.PersistedOperations.csproj
new file mode 100644
index 0000000..275b4bc
--- /dev/null
+++ b/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trax.Samples.PersistedOperations.csproj
@@ -0,0 +1,18 @@
+
+
+ net10.0
+ enable
+ enable
+ true
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/Trax.Samples.PersistedOperations.E2E/ApiTests/AdditionalCoverageTests.cs b/tests/Trax.Samples.PersistedOperations.E2E/ApiTests/AdditionalCoverageTests.cs
new file mode 100644
index 0000000..5398d76
--- /dev/null
+++ b/tests/Trax.Samples.PersistedOperations.E2E/ApiTests/AdditionalCoverageTests.cs
@@ -0,0 +1,216 @@
+using System.Net.Http.Json;
+using System.Text;
+using FluentAssertions;
+using Trax.Api.GraphQL.PersistedOperations.Storage;
+using Trax.Samples.PersistedOperations.E2E.Fixtures;
+
+namespace Trax.Samples.PersistedOperations.E2E.ApiTests;
+
+[TestFixture]
+[Category("E2E")]
+public class AdditionalCoverageTests : ApiTestBase
+{
+ private const string GreetDoc =
+ "query Greet($input: GreetInput!) { discover { greeting { greet(input: $input) { greeting } } } }";
+
+ // ----- Deactivated-id error shape -----
+
+ [Test]
+ public async Task DeactivatedId_ReturnsErrorsArrayWithCode()
+ {
+ await Store.UpsertAsync("deactivated_v1", GreetDoc, null, CancellationToken.None);
+ await Store.DeactivateAsync("deactivated_v1", null, "test", CancellationToken.None);
+
+ using var doc = await PostJsonAsync(
+ new { id = "deactivated_v1", variables = new { input = new { name = "x" } } }
+ );
+
+ doc.RootElement.TryGetProperty("errors", out var errors)
+ .Should()
+ .BeTrue("deactivated id should produce a GraphQL errors array");
+ errors.GetArrayLength().Should().BeGreaterThan(0);
+ errors[0].TryGetProperty("message", out _).Should().BeTrue();
+ }
+
+ // ----- Tenant scoping isolation (storage layer) -----
+
+ [Test]
+ public async Task TenantScoping_DocumentsAreIsolatedByTenantKey()
+ {
+ await Store.UpsertAsync(
+ "tenant_scope_v1",
+ GreetDoc,
+ new UpsertOptions { TenantKey = "tenant-a" },
+ CancellationToken.None
+ );
+ await Store.UpsertAsync(
+ "tenant_scope_v1",
+ GreetDoc + " # tenant-b variant",
+ new UpsertOptions { TenantKey = "tenant-b" },
+ CancellationToken.None
+ );
+
+ var a = await Store.GetAsync("tenant_scope_v1", "tenant-a", CancellationToken.None);
+ var b = await Store.GetAsync("tenant_scope_v1", "tenant-b", CancellationToken.None);
+ var none = await Store.GetAsync("tenant_scope_v1", null, CancellationToken.None);
+
+ a.Should().NotBeNull();
+ b.Should().NotBeNull();
+ a!.Document.Should().NotBe(b!.Document);
+ none.Should().BeNull("the null-tenant row was never written for this id");
+ }
+
+ // ----- Auth ordering: storage isn't read pre-auth -----
+
+ [Test]
+ public async Task PersistedRequest_DispatchesToTrain_ReturningExpectedData()
+ {
+ // The sample has no authentication wired (the production wiring is
+ // documented in the package README), so the strongest claim this
+ // test can make about auth ordering is: the persisted-op middleware
+ // and storage ARE consulted, and the request reaches the train. If
+ // the middleware were broken or the storage path bypassed, the
+ // response would not match the train's deterministic output.
+ await Store.UpsertAsync("auth_smoke_v1", GreetDoc, null, CancellationToken.None);
+
+ using var doc = await PostJsonAsync(
+ new { id = "auth_smoke_v1", variables = new { input = new { name = "anon" } } }
+ );
+
+ // Strong assertion: the response must contain the train's output,
+ // not just be "any GraphQL envelope". A broken pipeline would leave
+ // either an errors array OR a missing data field.
+ doc.RootElement.TryGetProperty("errors", out _)
+ .Should()
+ .BeFalse(doc.RootElement.GetRawText());
+ doc.RootElement.GetProperty("data")
+ .GetProperty("discover")
+ .GetProperty("greeting")
+ .GetProperty("greet")
+ .GetProperty("greeting")
+ .GetString()
+ .Should()
+ .Be("Hello, anon.");
+ }
+
+ // ----- Content-type variants -----
+
+ [Test]
+ public async Task ContentTypeWithCharset_AcceptedAndProducesCorrectTrainOutput()
+ {
+ await Store.UpsertAsync("content_v1", GreetDoc, null, CancellationToken.None);
+
+ var json = "{\"id\":\"content_v1\",\"variables\":{\"input\":{\"name\":\"Charset\"}}}";
+ using var content = new StringContent(json, Encoding.UTF8, "application/json");
+ content.Headers.ContentType!.CharSet = "utf-8";
+ var resp = await Http.PostAsync("/trax/graphql/", content);
+ ((int)resp.StatusCode).Should().Be(200);
+
+ var body = await resp.Content.ReadAsStringAsync();
+ body.Should()
+ .Contain(
+ "\"greeting\":\"Hello, Charset.\"",
+ "the request must reach the train and the train output must round-trip"
+ );
+ }
+
+ [Test]
+ public async Task EmptyJsonObject_RejectedByHotChocolateNotByMiddleware()
+ {
+ var json = "{}";
+ using var content = new StringContent(json, Encoding.UTF8, "application/json");
+ var resp = await Http.PostAsync("/trax/graphql/", content);
+ var body = await resp.Content.ReadAsStringAsync();
+ ((int)resp.StatusCode).Should().Be(400);
+ body.Should().NotContain("PERSISTED_OPERATION_REQUIRED");
+ }
+
+ // ----- Batched requests through real HC -----
+
+ [Test]
+ public async Task BatchedRequest_AllPersisted_PassesThroughMiddlewareToHC()
+ {
+ // HC v15's standard `/graphql` endpoint does not execute JSON-array
+ // batches; it returns HC0009 "Invalid GraphQL Request". The behavior
+ // under our control is the persisted-operations middleware: when
+ // every entry has a persisted id (no inline `query`), the middleware
+ // must NOT reject and must NOT short-circuit — it must pass the body
+ // through to HC. We assert that here by checking HC's parser error
+ // is reached, not the middleware's rejection error.
+ await Store.UpsertAsync("batch_a_v1", GreetDoc, null, CancellationToken.None);
+ await Store.UpsertAsync("batch_b_v1", GreetDoc, null, CancellationToken.None);
+
+ var json =
+ "[{\"id\":\"batch_a_v1\",\"variables\":{\"input\":{\"name\":\"Anna\"}}},"
+ + "{\"id\":\"batch_b_v1\",\"variables\":{\"input\":{\"name\":\"Bob\"}}}]";
+ using var content = new StringContent(json, Encoding.UTF8, "application/json");
+ var resp = await Http.PostAsync("/trax/graphql/", content);
+ var body = await resp.Content.ReadAsStringAsync();
+
+ body.Should()
+ .NotContain(
+ "PERSISTED_OPERATION_REQUIRED",
+ "the middleware must not reject batches whose entries are all persisted"
+ );
+ // HC's parser error code (HC0009) means the request reached HC's
+ // parser — i.e. our middleware passed it through. If the request had
+ // been rejected by the middleware, the body would carry our typed
+ // PERSISTED_OPERATION_REQUIRED code instead.
+ body.Should().Contain("HC0009", "request must reach HC, not be short-circuited by us");
+ }
+
+ [Test]
+ public async Task BatchedRequest_OneInlineQuery_RejectsWholeBatch()
+ {
+ await Store.UpsertAsync("batch_persisted_v1", GreetDoc, null, CancellationToken.None);
+
+ var json =
+ "[{\"id\":\"batch_persisted_v1\"},"
+ + "{\"query\":\"{ discover { greeting { greet(input: { name: \\\"y\\\" }) { greeting } } } }\"}]";
+ using var content = new StringContent(json, Encoding.UTF8, "application/json");
+ var resp = await Http.PostAsync("/trax/graphql/", content);
+ ((int)resp.StatusCode).Should().Be(400);
+ var body = await resp.Content.ReadAsStringAsync();
+ body.Should().Contain("PERSISTED_OPERATION_REQUIRED");
+ }
+
+ // ----- Multiple inputs proves correct execution -----
+
+ [Test]
+ public async Task SamePersistedOperation_DifferentVariables_DispatchesEachToTrain()
+ {
+ await Store.UpsertAsync("multivar_v1", GreetDoc, null, CancellationToken.None);
+
+ foreach (var name in new[] { "Anna", "Beth", "Carl", "Diane" })
+ {
+ using var doc = await PostJsonAsync(
+ new { id = "multivar_v1", variables = new { input = new { name } } }
+ );
+ doc.RootElement.GetProperty("data")
+ .GetProperty("discover")
+ .GetProperty("greeting")
+ .GetProperty("greet")
+ .GetProperty("greeting")
+ .GetString()
+ .Should()
+ .Be($"Hello, {name}.");
+ }
+ }
+
+ // ----- Shape-diff guardrail (E2E sanity) -----
+
+ [Test]
+ public async Task ShapeChangingEdit_IsRejectedAtTheStorageLayer()
+ {
+ await Store.UpsertAsync("shape_e2e_v1", GreetDoc, null, CancellationToken.None);
+
+ // Add greetedAt → response shape changes.
+ var changedShape =
+ "query Greet($input: GreetInput!) { discover { greeting { greet(input: $input) { greeting greetedAt } } } }";
+
+ Func act = () =>
+ Store.UpsertAsync("shape_e2e_v1", changedShape, null, CancellationToken.None);
+
+ await act.Should().ThrowAsync();
+ }
+}
diff --git a/tests/Trax.Samples.PersistedOperations.E2E/ApiTests/EnforcementTests.cs b/tests/Trax.Samples.PersistedOperations.E2E/ApiTests/EnforcementTests.cs
new file mode 100644
index 0000000..f9d1ee5
--- /dev/null
+++ b/tests/Trax.Samples.PersistedOperations.E2E/ApiTests/EnforcementTests.cs
@@ -0,0 +1,74 @@
+using System.Text.Json;
+using FluentAssertions;
+using Trax.Samples.PersistedOperations.E2E.Fixtures;
+
+namespace Trax.Samples.PersistedOperations.E2E.ApiTests;
+
+[TestFixture]
+[Category("E2E")]
+public class EnforcementTests : ApiTestBase
+{
+ private const string GreetDoc =
+ "query Greet($input: GreetInput!) { discover { greeting { greet(input: $input) { greeting } } } }";
+
+ [Test]
+ public async Task InlineQuery_IsRejectedWith400()
+ {
+ var resp = await PostAsync(
+ new { query = GreetDoc, variables = new { input = new { name = "Bob" } } }
+ );
+
+ ((int)resp.StatusCode).Should().Be(400);
+ var body = await resp.Content.ReadAsStringAsync();
+ body.Should().Contain("PERSISTED_OPERATION_REQUIRED");
+ }
+
+ [Test]
+ public async Task PersistedId_AfterUpload_DispatchesToTrainAndReturnsResult()
+ {
+ await Store.UpsertAsync("greet_v1", GreetDoc, options: null, CancellationToken.None);
+
+ using var doc = await PostJsonAsync(
+ new { id = "greet_v1", variables = new { input = new { name = "Alice" } } }
+ );
+
+ doc.RootElement.TryGetProperty("errors", out _)
+ .Should()
+ .BeFalse(doc.RootElement.GetRawText());
+ doc.RootElement.GetProperty("data")
+ .GetProperty("discover")
+ .GetProperty("greeting")
+ .GetProperty("greet")
+ .GetProperty("greeting")
+ .GetString()
+ .Should()
+ .Be("Hello, Alice.");
+ }
+
+ [Test]
+ public async Task PersistedId_NotInStore_ReturnsNotFoundError()
+ {
+ var resp = await PostAsync(
+ new { id = "nonexistent_v1", variables = new { input = new { name = "X" } } }
+ );
+ var body = await resp.Content.ReadAsStringAsync();
+ body.Should().Contain("errors");
+ }
+
+ [Test]
+ public async Task DevPrefixedOperation_BypassesEnforcement()
+ {
+ // The sample configures AllowOperationsMatching(id => id.StartsWith("dev_")).
+ // An inline query with a dev_-prefixed operation name passes through
+ // the middleware and HC executes it normally.
+ var resp = await PostAsync(
+ new
+ {
+ query = "query dev_explore($input: GreetInput!) { discover { greeting { greet(input: $input) { greeting } } } }",
+ operationName = "dev_explore",
+ variables = new { input = new { name = "Dev" } },
+ }
+ );
+ ((int)resp.StatusCode).Should().Be(200);
+ }
+}
diff --git a/tests/Trax.Samples.PersistedOperations.E2E/ApiTests/HotFixFlowTests.cs b/tests/Trax.Samples.PersistedOperations.E2E/ApiTests/HotFixFlowTests.cs
new file mode 100644
index 0000000..8f4c595
--- /dev/null
+++ b/tests/Trax.Samples.PersistedOperations.E2E/ApiTests/HotFixFlowTests.cs
@@ -0,0 +1,166 @@
+using FluentAssertions;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Trax.Api.GraphQL.PersistedOperations.Storage;
+using Trax.Effect.Data.Services.IDataContextFactory;
+using Trax.Samples.PersistedOperations.E2E.Fixtures;
+
+namespace Trax.Samples.PersistedOperations.E2E.ApiTests;
+
+[TestFixture]
+[Category("E2E")]
+public class HotFixFlowTests : ApiTestBase
+{
+ private const string GreetDoc =
+ "query Greet($input: GreetInput!) { discover { greeting { greet(input: $input) { greeting } } } }";
+
+ private const string LookupUserDoc =
+ "query LookupUser($input: LookupUserInput!) { discover { users { lookupUser(input: $input) { userId displayName email loginCount } } } }";
+
+ [Test]
+ public async Task DistinctIds_DispatchToDistinctTrains()
+ {
+ await Store.UpsertAsync("greet_distinct_v1", GreetDoc, null, CancellationToken.None);
+ await Store.UpsertAsync(
+ "lookupUser_distinct_v1",
+ LookupUserDoc,
+ null,
+ CancellationToken.None
+ );
+
+ using var greet = await PostJsonAsync(
+ new { id = "greet_distinct_v1", variables = new { input = new { name = "Alice" } } }
+ );
+ greet
+ .RootElement.GetProperty("data")
+ .GetProperty("discover")
+ .GetProperty("greeting")
+ .GetProperty("greet")
+ .GetProperty("greeting")
+ .GetString()
+ .Should()
+ .Be("Hello, Alice.");
+
+ using var lookup = await PostJsonAsync(
+ new
+ {
+ id = "lookupUser_distinct_v1",
+ variables = new { input = new { userId = "user-1" } },
+ }
+ );
+ lookup
+ .RootElement.GetProperty("data")
+ .GetProperty("discover")
+ .GetProperty("users")
+ .GetProperty("lookupUser")
+ .GetProperty("displayName")
+ .GetString()
+ .Should()
+ .Be("User user-1");
+ }
+
+ [Test]
+ public async Task PersistedOperation_WithMultipleVariableInputs_DispatchesEachCorrectly()
+ {
+ await Store.UpsertAsync("greet_multi_v1", GreetDoc, null, CancellationToken.None);
+
+ foreach (var name in new[] { "Alice", "Bob", "Charlie", "Dana" })
+ {
+ using var doc = await PostJsonAsync(
+ new { id = "greet_multi_v1", variables = new { input = new { name } } }
+ );
+ doc.RootElement.GetProperty("data")
+ .GetProperty("discover")
+ .GetProperty("greeting")
+ .GetProperty("greet")
+ .GetProperty("greeting")
+ .GetString()
+ .Should()
+ .Be($"Hello, {name}.");
+ }
+ }
+
+ [Test]
+ public async Task PersistedOperation_LookupUser_ReturnsTrainResolverOutput()
+ {
+ await Store.UpsertAsync("lookup_v1", LookupUserDoc, null, CancellationToken.None);
+
+ using var doc = await PostJsonAsync(
+ new { id = "lookup_v1", variables = new { input = new { userId = "user-99" } } }
+ );
+
+ var user = doc
+ .RootElement.GetProperty("data")
+ .GetProperty("discover")
+ .GetProperty("users")
+ .GetProperty("lookupUser");
+
+ user.GetProperty("userId").GetString().Should().Be("user-99");
+ user.GetProperty("displayName").GetString().Should().Be("User user-99");
+ user.GetProperty("email").GetString().Should().Be("user-99@example.test");
+ user.GetProperty("loginCount").GetInt32().Should().BeGreaterThanOrEqualTo(0);
+ }
+
+ [Test]
+ public async Task PersistedOperationHistory_AccumulatesEveryMutationInOrder()
+ {
+ // Drive the storage through Upsert → Upsert (rewrite) → Deactivate →
+ // Restore, then assert the history table contains exactly four rows
+ // in the right ChangeType order. The previous version of this test
+ // queried ListAsync (live rows) and only confirmed the live row
+ // existed — it would have passed even if no history rows had been
+ // written.
+ const string id = "history_check_v1";
+ await Store.UpsertAsync(id, GreetDoc, null, CancellationToken.None);
+ await Store.UpsertAsync(id, GreetDoc + " # rewrite", null, CancellationToken.None);
+ await Store.DeactivateAsync(id, null, "test cleanup", CancellationToken.None);
+ await Store.RestoreAsync(id, null, CancellationToken.None);
+
+ var factory =
+ SharedApiSetup.Factory!.Services.GetRequiredService();
+ var ctx = await factory.CreateDbContextAsync(CancellationToken.None);
+
+ var rows = await ctx
+ .PersistedOperationHistories.Where(h => h.Id == id)
+ .OrderBy(h => h.ChangedAt)
+ .ThenBy(h => h.HistoryId)
+ .Select(h => h.ChangeType)
+ .ToListAsync();
+
+ rows.Should().Equal("Upsert", "Upsert", "Deactivate", "Restore");
+
+ var deactivateRow = await ctx.PersistedOperationHistories.FirstAsync(h =>
+ h.Id == id && h.ChangeType == "Deactivate"
+ );
+ deactivateRow
+ .ChangedReason.Should()
+ .Be("test cleanup", "Deactivate must record the operator-supplied reason");
+ }
+
+ [Test]
+ public async Task DeactivateThenRestore_DispatchResumesAfterRestore()
+ {
+ await Store.UpsertAsync("lifecycle_v1", GreetDoc, null, CancellationToken.None);
+ await Store.DeactivateAsync("lifecycle_v1", null, "test", CancellationToken.None);
+
+ var resp = await PostAsync(
+ new { id = "lifecycle_v1", variables = new { input = new { name = "X" } } }
+ );
+ var body = await resp.Content.ReadAsStringAsync();
+ body.Should().Contain("errors");
+
+ await Store.RestoreAsync("lifecycle_v1", null, CancellationToken.None);
+
+ using var doc = await PostJsonAsync(
+ new { id = "lifecycle_v1", variables = new { input = new { name = "Y" } } }
+ );
+ doc.RootElement.GetProperty("data")
+ .GetProperty("discover")
+ .GetProperty("greeting")
+ .GetProperty("greet")
+ .GetProperty("greeting")
+ .GetString()
+ .Should()
+ .Be("Hello, Y.");
+ }
+}
diff --git a/tests/Trax.Samples.PersistedOperations.E2E/Fixtures/ApiTestBase.cs b/tests/Trax.Samples.PersistedOperations.E2E/Fixtures/ApiTestBase.cs
new file mode 100644
index 0000000..6a1e43d
--- /dev/null
+++ b/tests/Trax.Samples.PersistedOperations.E2E/Fixtures/ApiTestBase.cs
@@ -0,0 +1,50 @@
+using System.Net.Http.Json;
+using System.Text.Json;
+using Microsoft.Extensions.DependencyInjection;
+using Trax.Api.GraphQL.PersistedOperations.Storage;
+
+namespace Trax.Samples.PersistedOperations.E2E.Fixtures;
+
+///
+/// Shared base for E2E tests. Provides an HTTP client wired to the in-process
+/// API and a fresh reference per test.
+///
+public abstract class ApiTestBase
+{
+ protected HttpClient Http { get; private set; } = null!;
+ protected IPersistedOperationStore Store { get; private set; } = null!;
+
+ [SetUp]
+ public async Task BaseSetUpAsync()
+ {
+ if (SharedApiSetup.Skipped || SharedApiSetup.Factory is null)
+ Assert.Ignore("Postgres / API factory not reachable. Run docker compose up -d.");
+
+ // Each test starts with empty persisted-operation tables so id reuse
+ // across cases does not trigger the shape-diff guardrail against
+ // stale state.
+ await SharedApiSetup.ClearAsync(SharedApiSetup.Factory.Services);
+
+ Http = SharedApiSetup.Factory.CreateClient();
+ Store = SharedApiSetup.Factory.Services.GetRequiredService();
+ }
+
+ [TearDown]
+ public void BaseTearDown() => Http?.Dispose();
+
+ ///
+ /// POST a GraphQL request body, return the raw response.
+ ///
+ protected async Task PostAsync(object body) =>
+ await Http.PostAsJsonAsync("/trax/graphql/", body);
+
+ ///
+ /// POST and parse the response body as JSON.
+ ///
+ protected async Task PostJsonAsync(object body)
+ {
+ var resp = await PostAsync(body);
+ var text = await resp.Content.ReadAsStringAsync();
+ return JsonDocument.Parse(text);
+ }
+}
diff --git a/tests/Trax.Samples.PersistedOperations.E2E/Fixtures/PersistedOperationsApiFactory.cs b/tests/Trax.Samples.PersistedOperations.E2E/Fixtures/PersistedOperationsApiFactory.cs
new file mode 100644
index 0000000..bc3c0c5
--- /dev/null
+++ b/tests/Trax.Samples.PersistedOperations.E2E/Fixtures/PersistedOperationsApiFactory.cs
@@ -0,0 +1,26 @@
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Mvc.Testing;
+
+namespace Trax.Samples.PersistedOperations.E2E.Fixtures;
+
+///
+/// WebApplicationFactory hosting Trax.Samples.PersistedOperations.Api
+/// in-process. Tests POST against the GraphQL endpoint via the factory's
+/// HttpClient (no Kestrel binding, no port collisions).
+///
+public sealed class PersistedOperationsApiFactory : WebApplicationFactory
+{
+ ///
+ /// Override knobs for individual test classes (e.g., flip
+ /// RequirePersisted off for shadow-mode tests). Default is null
+ /// (use whatever the sample's Program.cs configures).
+ ///
+ public Action? Configure { get; set; }
+
+ protected override void ConfigureWebHost(IWebHostBuilder builder)
+ {
+ // The sample reads connection strings from configuration; in-process
+ // tests assume the same docker-compose Postgres the sample uses.
+ Configure?.Invoke(builder);
+ }
+}
diff --git a/tests/Trax.Samples.PersistedOperations.E2E/Fixtures/SharedApiSetup.cs b/tests/Trax.Samples.PersistedOperations.E2E/Fixtures/SharedApiSetup.cs
new file mode 100644
index 0000000..a44d8be
--- /dev/null
+++ b/tests/Trax.Samples.PersistedOperations.E2E/Fixtures/SharedApiSetup.cs
@@ -0,0 +1,75 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Trax.Api.GraphQL.PersistedOperations.Storage;
+using Trax.Effect.Data.Services.IDataContextFactory;
+using Trax.Samples.PersistedOperations.E2E.Fixtures;
+
+namespace Trax.Samples.PersistedOperations.E2E;
+
+///
+/// One-time setup: builds the API factory, ensures the persisted-operation
+/// store is reachable, and seeds a clean state.
+///
+[SetUpFixture]
+public class SharedApiSetup
+{
+ public static PersistedOperationsApiFactory? Factory { get; private set; }
+
+ public static bool Skipped { get; private set; }
+
+ [OneTimeSetUp]
+ public async Task OneTimeSetUp()
+ {
+ try
+ {
+ Factory = new PersistedOperationsApiFactory();
+ _ = Factory.Services;
+ await ClearAsync(Factory.Services);
+ }
+ catch (Exception ex)
+ {
+ Skipped = true;
+ await (Factory?.DisposeAsync().AsTask() ?? Task.CompletedTask);
+ Factory = null;
+ if (
+ ex is not Npgsql.NpgsqlException
+ && ex.InnerException is not Npgsql.NpgsqlException
+ && ex is not System.Net.Sockets.SocketException
+ )
+ throw;
+ }
+ }
+
+ [OneTimeTearDown]
+ public async Task OneTimeTearDown()
+ {
+ if (Factory is null)
+ return;
+ try
+ {
+ await ClearAsync(Factory.Services);
+ }
+ catch
+ {
+ // Best-effort.
+ }
+ await Factory.DisposeAsync();
+ }
+
+ ///
+ /// Hard-deletes every row in trax.persisted_operation and
+ /// trax.persisted_operation_history so each test starts clean.
+ /// Tests in this suite reuse a small set of ids (e.g. greet_v1)
+ /// across cases and rely on the shape-diff guardrail being evaluated
+ /// against a fresh insert, not a stale row from a previous test.
+ ///
+ public static async Task ClearAsync(IServiceProvider services)
+ {
+ var factory = services.GetRequiredService();
+ var ctx = await factory.CreateDbContextAsync(CancellationToken.None);
+ await ctx.PersistedOperationHistories.ExecuteDeleteAsync();
+ await ctx.PersistedOperations.ExecuteDeleteAsync();
+ if (ctx is IAsyncDisposable async)
+ await async.DisposeAsync();
+ }
+}
diff --git a/tests/Trax.Samples.PersistedOperations.E2E/Trax.Samples.PersistedOperations.E2E.csproj b/tests/Trax.Samples.PersistedOperations.E2E/Trax.Samples.PersistedOperations.E2E.csproj
new file mode 100644
index 0000000..fa4bb4b
--- /dev/null
+++ b/tests/Trax.Samples.PersistedOperations.E2E/Trax.Samples.PersistedOperations.E2E.csproj
@@ -0,0 +1,29 @@
+
+
+ net10.0
+ enable
+ enable
+ false
+ true
+ Trax.Samples.PersistedOperations.E2E
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+