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
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using Trax.Api.GraphQL.PersistedOperations.Broadcasting;
using Trax.Api.GraphQL.PersistedOperations.Configuration;
using Trax.Api.GraphQL.PersistedOperations.Storage;
using Trax.Api.GraphQL.PersistedOperations.Storage.Validation;

namespace Trax.Api.GraphQL.PersistedOperations.Extensions;

Expand Down Expand Up @@ -44,6 +45,7 @@ string databaseConnectionString
IPersistedOperationBroadcaster,
NoOpPersistedOperationBroadcaster
>();
services.TryAddSingleton<IPersistedOperationValidator, NoOpPersistedOperationValidator>();

services.AddSingleton<DbPersistedOperationStorage>();
services.AddSingleton<IPersistedOperationStore>(sp =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using Trax.Api.GraphQL.PersistedOperations.Configuration;
using Trax.Api.GraphQL.PersistedOperations.Middleware;
using Trax.Api.GraphQL.PersistedOperations.Storage;
using Trax.Api.GraphQL.PersistedOperations.Storage.Validation;

namespace Trax.Api.GraphQL.PersistedOperations.Extensions;

Expand Down Expand Up @@ -89,6 +90,27 @@ Action<PersistedOperationsBuilder> configure
>();
}

// Validator: HotChocolate-backed in this path (we have a schema in process).
// Replace overrides the no-op default from AddPersistedOperationStore if it
// was also called.
services.Replace(
ServiceDescriptor.Singleton<IPersistedOperationValidator>(
sp => new HotChocolateSchemaValidator(sp)
)
);

// Capability marker: presence in DI signals to consumers (dashboard)
// that the full persisted-operations subsystem is wired in.
services.AddSingleton<IPersistedOperationsCapability, PersistedOperationsCapability>();

// Management mutations + queries. Scanned via the existing
// TraxGraphQLBuilder.AddTypeExtensions helper. Also flip the
// operations-exposed flags so AddTraxGraphQL emits the OperationsQueries
// and OperationsMutations namespaces that our type extensions graft onto.
builder.ExposeOperationQueries();
builder.ExposeOperationMutations();
builder.AddTypeExtensions(typeof(GraphQL.PersistedOperationMutations).Assembly);

// Storage: implements both IPersistedOperationStore and the HC hot-path.
services.AddSingleton<DbPersistedOperationStorage>();
services.AddSingleton<IPersistedOperationStore>(sp =>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
namespace Trax.Api.GraphQL.PersistedOperations.GraphQL.Models;

/// <summary>Input for the <c>deactivatePersistedOperation</c> mutation.</summary>
public sealed record DeactivatePersistedOperationInput(
string Id,
string Reason,
string? TenantKey = null
);

/// <summary>Result of <c>deactivatePersistedOperation</c>.</summary>
public sealed record DeactivatePersistedOperationPayload(
PersistedOperationDto? Operation,
IReadOnlyList<PersistedOperationError> Errors
)
{
/// <summary>True when the change succeeded.</summary>
public bool Success => Operation is not null && Errors.Count == 0;
}

/// <summary>Input for the <c>restorePersistedOperation</c> mutation.</summary>
public sealed record RestorePersistedOperationInput(string Id, string? TenantKey = null);

/// <summary>Result of <c>restorePersistedOperation</c>.</summary>
public sealed record RestorePersistedOperationPayload(
PersistedOperationDto? Operation,
IReadOnlyList<PersistedOperationError> Errors
)
{
/// <summary>True when the change succeeded.</summary>
public bool Success => Operation is not null && Errors.Count == 0;
}

/// <summary>Filter for the <c>persistedOperations</c> query.</summary>
public sealed record PersistedOperationFilter(
bool? IsActive = null,
string? TenantKey = null,
string? IdStartsWith = null
);

/// <summary>Paged result for the <c>persistedOperations</c> query.</summary>
public sealed record PersistedOperationsPage(
IReadOnlyList<PersistedOperationDto> Items,
int TotalCount
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using Trax.Effect.Models.PersistedOperation;

namespace Trax.Api.GraphQL.PersistedOperations.GraphQL.Models;

/// <summary>
/// GraphQL surface for a persisted operation row.
/// </summary>
public sealed record PersistedOperationDto(
string Id,
string? TenantKey,
string OperationName,
int Version,
string Document,
string ShapeFingerprint,
bool IsActive,
string? DeprecationReason,
string? Description,
DateTime CreatedAt,
DateTime UpdatedAt
)
{
internal static PersistedOperationDto From(PersistedOperation row) =>
new(
row.Id,
row.TenantKey,
row.OperationName,
row.Version,
row.Document,
row.ShapeFingerprint,
row.IsActive,
row.DeprecationReason,
row.Description,
row.CreatedAt,
row.UpdatedAt
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
using Trax.Api.GraphQL.PersistedOperations.Storage;
using Trax.Api.GraphQL.PersistedOperations.Storage.Exceptions;

namespace Trax.Api.GraphQL.PersistedOperations.GraphQL.Models;

/// <summary>
/// Structured error surfaced inside a mutation payload. Mutations never throw
/// to the GraphQL caller; failures are returned in the <c>errors</c> field
/// with a stable <c>code</c> so clients can branch without string-matching.
/// </summary>
public sealed record PersistedOperationError(
string Code,
string Message,
IReadOnlyList<PersistedOperationErrorLocation>? Locations,
IReadOnlyList<string>? Path,
string? OldFingerprint,
string? NewFingerprint
)
{
internal static PersistedOperationError FromParseException(PersistedOperationParseException ex)
{
var locs =
ex.Line is { } line && ex.Column is { } column
? new[] { new PersistedOperationErrorLocation(line, column) }
: null;
return new(
ex.Code,
ex.OriginalMessage,
locs,
Path: null,
OldFingerprint: null,
NewFingerprint: null
);
}

internal static IEnumerable<PersistedOperationError> FromValidationException(
PersistedOperationValidationException ex
)
{
foreach (var failure in ex.Failures)
{
var locs =
failure.Locations.Count > 0
? failure
.Locations.Select(l => new PersistedOperationErrorLocation(
l.Line,
l.Column
))
.ToArray()
: null;
var path =
failure.Path.Count > 0
? failure.Path.Select(p => p.ToString() ?? "").ToArray()
: null;
yield return new PersistedOperationError(
ex.Code,
failure.Message,
locs,
path,
null,
null
);
}
}

internal static PersistedOperationError FromShapeDiff(ShapeDiffViolationException ex) =>
new(ex.Code, ex.Message, Locations: null, Path: null, ex.OldFingerprint, ex.NewFingerprint);

/// <summary>Build a NOT_FOUND error.</summary>
public static PersistedOperationError NotFound(string id) =>
new(
"NOT_FOUND",
$"Persisted operation '{id}' was not found.",
Locations: null,
Path: null,
OldFingerprint: null,
NewFingerprint: null
);
}

/// <summary>1-based location in the candidate document.</summary>
public sealed record PersistedOperationErrorLocation(int Line, int Column);
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace Trax.Api.GraphQL.PersistedOperations.GraphQL.Models;

/// <summary>One row from the operation's audit log.</summary>
public sealed record PersistedOperationHistoryDto(
long HistoryId,
string Id,
string? TenantKey,
string Document,
string ShapeFingerprint,
string ChangeType,
DateTime ChangedAt,
string? ChangedReason
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace Trax.Api.GraphQL.PersistedOperations.GraphQL.Models;

/// <summary>Input for the <c>uploadPersistedOperation</c> mutation.</summary>
public sealed record UploadPersistedOperationInput(
string Id,
string Document,
string? Description = null,
bool BypassShapeDiff = false,
int Version = 0,
string? TenantKey = null
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
namespace Trax.Api.GraphQL.PersistedOperations.GraphQL.Models;

/// <summary>
/// Result of <c>uploadPersistedOperation</c>. Exactly one of
/// <see cref="Operation"/> and <see cref="Errors"/> is populated.
/// </summary>
public sealed record UploadPersistedOperationPayload(
PersistedOperationDto? Operation,
IReadOnlyList<PersistedOperationError> Errors
)
{
/// <summary>True when the upload succeeded.</summary>
public bool Success => Operation is not null && Errors.Count == 0;

internal static UploadPersistedOperationPayload Ok(PersistedOperationDto op) =>
new(op, Array.Empty<PersistedOperationError>());

internal static UploadPersistedOperationPayload Fail(params PersistedOperationError[] errors) =>
new(null, errors);

internal static UploadPersistedOperationPayload Fail(
IEnumerable<PersistedOperationError> errors
) => new(null, errors.ToArray());
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using HotChocolate.Types;
using Trax.Api.GraphQL.Mutations;
using Trax.Api.GraphQL.Queries;

namespace Trax.Api.GraphQL.PersistedOperations.GraphQL;

/// <summary>
/// Grafts <c>persistedOperations</c> onto <c>operations</c> on the query side
/// of the schema. Mirrors how <c>operations.deadLetters</c> and
/// <c>operations.manifestGroups</c> are wired by the base
/// <c>Trax.Api.GraphQL</c> package.
/// </summary>
[ExtendObjectType(typeof(OperationsQueries))]
public sealed class OperationsQueriesPersistedOperationsExtension
{
/// <summary>
/// Nested namespace exposing persisted-operation queries (paged list,
/// single lookup, audit history).
/// </summary>
public PersistedOperationQueries PersistedOperations() => new();
}

/// <summary>
/// Grafts <c>persistedOperations</c> onto <c>operations</c> on the mutation
/// side of the schema.
/// </summary>
[ExtendObjectType(typeof(OperationsMutations))]
public sealed class OperationsMutationsPersistedOperationsExtension
{
/// <summary>
/// Nested namespace exposing persisted-operation mutations (upload,
/// deactivate, restore).
/// </summary>
public PersistedOperationMutations PersistedOperations() => new();
}
Loading
Loading