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: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
# Changelog

## Unreleased
## 0.6.0 — 2026-08-23

- Added exact immutable Event configuration binding reads and compare-and-set
attach/detach through `Events.RetrieveConfigurationBindingAsync` and
`Events.UpdateConfigurationBindingAsync`. Updates remain deliberately single-attempt.

## 0.5.0 — 2026-08-21

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,7 @@ await client.SendAsync(HttpMethod.Post, "/v1/events/ev_1/some-new-route",
| --- | --- |
| `Charts` | `ListAsync` `ListAllAsync` `CreateAsync` `RetrieveAsync` `UpdateAsync` `DeleteAsync` `CopyAsync` `ArchiveAsync` `UnarchiveAsync` `PublishAsync` |
| `Templates` | `InstantiateTemplateAsync` |
| `Events` | `ListAsync` `ListAllAsync` `CreateAsync` `RetrieveAsync` `UpdateAsync` `DeleteAsync` `UpdateChartAsync` `CloseAsync` `ReopenAsync` `ArchiveAsync` `RetrieveHoldTtlAsync` `UpdateHoldTtlAsync` `ListTicketReleasesAsync` `UpdateTicketReleasesAsync` `CloseTicketReleaseAsync` `RetrieveReportAsync` `RetrieveLogAsync` |
| `Events` | `ListAsync` `ListAllAsync` `CreateAsync` `RetrieveAsync` `RetrieveConfigurationBindingAsync` `UpdateConfigurationBindingAsync` `UpdateAsync` `DeleteAsync` `UpdateChartAsync` `CloseAsync` `ReopenAsync` `ArchiveAsync` `RetrieveHoldTtlAsync` `UpdateHoldTtlAsync` `ListTicketReleasesAsync` `UpdateTicketReleasesAsync` `CloseTicketReleaseAsync` `RetrieveReportAsync` `RetrieveLogAsync` |
| `Channels` | `ListAsync` `CreateAsync` `UpdateAsync` `UpdateAssignmentsAsync` `ListAllocationAsync` `RetrieveAccessPreviewAsync` `RetrieveReportAsync` `PauseAsync` `UnpauseAsync` `ArchiveAsync` `CreateBuyerAccessSessionAsync` `ListBuyerAccessSessionsAsync` `RevokeBuyerAccessSessionAsync` |
| `Inventory` | `HoldAsync` `HoldBestAvailableAsync` `BookBestAvailableAsync` `ExtendHoldAsync` `RetrieveHoldAsync` `ReleaseAsync` `BookAsync` `BookLabelsAsync` `BoxOfficeBookAsync` `UnbookAsync` `BlockAsync` `UnblockAsync` `UnblockAllAsync` `RetrieveAvailabilityAsync` `UpdateAvailabilityAsync` `ListBookingsAsync` `RetrieveBookingAsync` |
| `Sessions` | `CreateManageSessionAsync` `RevokeManageSessionAsync` `CreateDesignerSessionAsync` `RevokeDesignerSessionAsync` |
Expand Down
2 changes: 1 addition & 1 deletion src/SeatLayer/SeatLayer.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

<PropertyGroup>
<PackageId>SeatLayer</PackageId>
<Version>0.5.0</Version>
<Version>0.6.0</Version>
<Authors>SeatLayer</Authors>
<Company>SeatLayer</Company>
<Description>Official .NET server SDK for the SeatLayer reserved-seating API. Server-side only.</Description>
Expand Down
51 changes: 51 additions & 0 deletions src/SeatLayer/Services.cs
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,27 @@ public sealed class TicketReleaseReplaceRequest
public required IReadOnlyList<TicketReleaseInput> Releases { get; set; }
}

/// <summary>Selects one immutable published Event configuration version.</summary>
/// <remarks>Configuration identity remains separate from the chart's venue geometry.</remarks>
public sealed class EventConfigurationReference
{
/// <summary>Configuration library id.</summary>
public required string Id { get; set; }

/// <summary>Exact immutable version to attach.</summary>
public required long Version { get; set; }
}

/// <summary>Compare-and-set request for an Event's configuration binding.</summary>
public sealed class EventConfigurationBindingUpdateRequest
{
/// <summary>Revision returned by the latest binding read.</summary>
public required long ExpectedRevision { get; set; }

/// <summary>Published version to attach, or null to explicitly detach.</summary>
public EventConfigurationReference? Configuration { get; set; }
}

/// <summary>Event lifecycle, metadata and reports.</summary>
public sealed class EventsService
{
Expand Down Expand Up @@ -433,6 +454,36 @@ public async Task<Page> ListAsync(
string eventKey, CancellationToken cancellationToken = default)
=> _client.GetAsync($"/v1/events/{SeatLayerClient.Escape(eventKey)}", null, cancellationToken);

/// <summary>Reads the Event's exact immutable configuration selection and audit trail.</summary>
public Task<IReadOnlyDictionary<string, object?>> RetrieveConfigurationBindingAsync(
string eventKey, CancellationToken cancellationToken = default)
=> _client.GetAsync(
$"/v1/events/{SeatLayerClient.Escape(eventKey)}/event-configuration",
null,
cancellationToken);

/// <summary>Attaches an exact published configuration version, or explicitly detaches it.</summary>
/// <remarks>
/// The expected revision prevents concurrent administrative changes from silently overwriting
/// each other. This mutation remains single-attempt because it has no header-replay contract.
/// </remarks>
public Task<IReadOnlyDictionary<string, object?>> UpdateConfigurationBindingAsync(
string eventKey,
EventConfigurationBindingUpdateRequest request,
CancellationToken cancellationToken = default)
{
// Body.Of removes null optional values, but null is the explicit detach instruction here.
var body = new Dictionary<string, object?>
{
["expectedRevision"] = request.ExpectedRevision,
["configuration"] = request.Configuration,
};
return _client.PutAsync(
$"/v1/events/{SeatLayerClient.Escape(eventKey)}/event-configuration",
body,
cancellationToken);
}

/// <summary>Updates event metadata.</summary>
public Task<IReadOnlyDictionary<string, object?>> UpdateAsync(
string eventKey, IDictionary<string, object?> fields, CancellationToken cancellationToken = default)
Expand Down
62 changes: 62 additions & 0 deletions tests/SeatLayer.Tests/ClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,68 @@ await Assert.ThrowsAsync<SeatLayerRateLimitException>(
Assert.False(handler.Calls[2].Headers.Contains("Idempotency-Key"));
}

[Fact]
public async Task EventConfigurationBindingUsesExactVersionsExplicitDetachAndSingleAttempts()
{
const string binding = "{\"configuration\":{\"id\":\"ec_touring\",\"version\":3}," +
"\"revision\":7,\"changedBy\":\"api-key:key_1\",\"changedAt\":123,\"audit\":[" +
"{\"id\":\"eca_1\",\"from\":null,\"to\":{\"id\":\"ec_touring\",\"version\":3}," +
"\"revision\":7,\"actor\":\"api-key:key_1\",\"createdAt\":123}]}";
var retryNow = new Dictionary<string, string> { ["Retry-After"] = "0" };
var (client, handler) = Build(new[]
{
Ok(binding),
Ok(binding),
Ok("{\"configuration\":null,\"revision\":8,\"changedBy\":null,\"changedAt\":null,\"audit\":[]}"),
Status((HttpStatusCode)429, "{\"error\":\"rate_limited\"}", retryNow),
});

var retrieved = await client.Events.RetrieveConfigurationBindingAsync("ev / main");
var attached = await client.Events.UpdateConfigurationBindingAsync(
"ev / main",
new EventConfigurationBindingUpdateRequest
{
ExpectedRevision = 6,
Configuration = new EventConfigurationReference { Id = "ec_touring", Version = 3 },
});
var detached = await client.Events.UpdateConfigurationBindingAsync(
"ev / main",
new EventConfigurationBindingUpdateRequest { ExpectedRevision = 7, Configuration = null });
await Assert.ThrowsAsync<SeatLayerRateLimitException>(
() => client.Events.UpdateConfigurationBindingAsync(
"ev_1",
new EventConfigurationBindingUpdateRequest { ExpectedRevision = 8, Configuration = null }));

var configuration = Assert.IsAssignableFrom<IReadOnlyDictionary<string, object?>>(
retrieved["configuration"]);
Assert.Equal("ec_touring", configuration["id"]);
var audit = Assert.IsAssignableFrom<IReadOnlyList<object?>>(retrieved["audit"]);
var firstAudit = Assert.IsAssignableFrom<IReadOnlyDictionary<string, object?>>(audit[0]);
Assert.Null(firstAudit["from"]);
Assert.NotNull(attached["configuration"]);
Assert.Null(detached["configuration"]);

Assert.Equal(4, handler.Calls.Count);
for (var index = 0; index < 3; index++)
{
Assert.Equal(
"https://api.seatlayer.io/v1/events/ev%20%2F%20main/event-configuration",
handler.Calls[index].Url.AbsoluteUri);
}
Assert.Equal(HttpMethod.Get, handler.Calls[0].Method);
Assert.Equal(HttpMethod.Put, handler.Calls[1].Method);
Assert.Equal(HttpMethod.Put, handler.Calls[2].Method);
using var attachBody = JsonDocument.Parse(handler.Calls[1].Body!);
Assert.Equal(6, attachBody.RootElement.GetProperty("expectedRevision").GetInt64());
Assert.Equal("ec_touring", attachBody.RootElement.GetProperty("configuration")
.GetProperty("id").GetString());
using var detachBody = JsonDocument.Parse(handler.Calls[2].Body!);
Assert.Equal(JsonValueKind.Null, detachBody.RootElement.GetProperty("configuration").ValueKind);
Assert.False(handler.Calls[1].Headers.Contains("Idempotency-Key"));
Assert.False(handler.Calls[2].Headers.Contains("Idempotency-Key"));
Assert.False(handler.Calls[3].Headers.Contains("Idempotency-Key"));
}

[Fact]
public async Task HonoursCallerIdempotencyKey()
{
Expand Down
Loading