From 46a4309dea5d54412a95f4f3c92dede287ee965a Mon Sep 17 00:00:00 2001 From: woksin Date: Sun, 23 Aug 2026 01:00:09 +0200 Subject: [PATCH 01/12] Add focused Chronicle backend sample --- Chronicle/Backend/AssemblyInfo.cs | 6 + .../Backend.Specs/Backend.Specs.csproj | 22 ++ .../Backend.Specs/GlobalUsings.Specs.cs | 11 + .../for_Timeline/when_reading_history.cs | 37 ++++ .../for_Timeline/when_recording_an_entry.cs | 31 +++ Chronicle/Backend/Backend.csproj | 10 + Chronicle/Backend/ChronicleConfiguration.cs | 12 ++ Chronicle/Backend/ChronicleReadiness.cs | 36 ++++ Chronicle/Backend/Program.cs | 16 ++ Chronicle/Backend/README.md | 196 ++++++++++++++++++ Chronicle/Backend/Timeline.cs | 30 +++ Chronicle/Backend/TimelineEndpoints.cs | 79 +++++++ Chronicle/Backend/TimelineEntryRecorded.cs | 13 ++ Chronicle/Backend/TimelineId.cs | 31 +++ 14 files changed, 530 insertions(+) create mode 100644 Chronicle/Backend/AssemblyInfo.cs create mode 100644 Chronicle/Backend/Backend.Specs/Backend.Specs.csproj create mode 100644 Chronicle/Backend/Backend.Specs/GlobalUsings.Specs.cs create mode 100644 Chronicle/Backend/Backend.Specs/for_Timeline/when_reading_history.cs create mode 100644 Chronicle/Backend/Backend.Specs/for_Timeline/when_recording_an_entry.cs create mode 100644 Chronicle/Backend/Backend.csproj create mode 100644 Chronicle/Backend/ChronicleConfiguration.cs create mode 100644 Chronicle/Backend/ChronicleReadiness.cs create mode 100644 Chronicle/Backend/Program.cs create mode 100644 Chronicle/Backend/README.md create mode 100644 Chronicle/Backend/Timeline.cs create mode 100644 Chronicle/Backend/TimelineEndpoints.cs create mode 100644 Chronicle/Backend/TimelineEntryRecorded.cs create mode 100644 Chronicle/Backend/TimelineId.cs diff --git a/Chronicle/Backend/AssemblyInfo.cs b/Chronicle/Backend/AssemblyInfo.cs new file mode 100644 index 00000000..aefbfb2e --- /dev/null +++ b/Chronicle/Backend/AssemblyInfo.cs @@ -0,0 +1,6 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Backend.Specs")] diff --git a/Chronicle/Backend/Backend.Specs/Backend.Specs.csproj b/Chronicle/Backend/Backend.Specs/Backend.Specs.csproj new file mode 100644 index 00000000..e231972a --- /dev/null +++ b/Chronicle/Backend/Backend.Specs/Backend.Specs.csproj @@ -0,0 +1,22 @@ + + + Chronicle.Backend.Specs + false + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/Chronicle/Backend/Backend.Specs/GlobalUsings.Specs.cs b/Chronicle/Backend/Backend.Specs/GlobalUsings.Specs.cs new file mode 100644 index 00000000..35e2aa9d --- /dev/null +++ b/Chronicle/Backend/Backend.Specs/GlobalUsings.Specs.cs @@ -0,0 +1,11 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +global using System.Collections.Immutable; + +global using Cratis.Chronicle.Events; +global using Cratis.Chronicle.EventSequences; +global using Cratis.Execution; +global using Cratis.Specifications; +global using NSubstitute; +global using Xunit; diff --git a/Chronicle/Backend/Backend.Specs/for_Timeline/when_reading_history.cs b/Chronicle/Backend/Backend.Specs/for_Timeline/when_reading_history.cs new file mode 100644 index 00000000..c38e0c52 --- /dev/null +++ b/Chronicle/Backend/Backend.Specs/for_Timeline/when_reading_history.cs @@ -0,0 +1,37 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Chronicle.Backend.Specs.for_Timeline; + +public class when_reading_history : Specification +{ + const string FirstEntry = "The first fact."; + const string SecondEntry = "The next fact."; + IEventLog _eventLog; + Timeline _timeline; + TimelineId _timelineId; + IReadOnlyList _history; + + void Establish() + { + _eventLog = Substitute.For(); + _eventLog + .GetForEventSourceIdAndEventTypes( + Arg.Any(), + Arg.Any>()) + .Returns(Task.FromResult>( + ImmutableList.Create( + AppendedEvent.EmptyWithContent(new TimelineEntryRecorded(FirstEntry)), + AppendedEvent.EmptyWithContent(new TimelineEntryRecorded(SecondEntry))))); + _timeline = new(_eventLog); + _timelineId = TimelineId.New(); + } + + async Task Because() => _history = await _timeline.GetHistory(_timelineId); + + [Fact] void should_return_the_recorded_entries() => _history.Select(_ => _.Text).ShouldContainOnly([FirstEntry, SecondEntry]); + [Fact] + async Task should_query_with_the_typed_event_source_identifier() => await _eventLog.Received(1).GetForEventSourceIdAndEventTypes( + (EventSourceId)_timelineId, + Arg.Is>(types => types.Single() == typeof(TimelineEntryRecorded).GetEventType())); +} diff --git a/Chronicle/Backend/Backend.Specs/for_Timeline/when_recording_an_entry.cs b/Chronicle/Backend/Backend.Specs/for_Timeline/when_recording_an_entry.cs new file mode 100644 index 00000000..b197d1a3 --- /dev/null +++ b/Chronicle/Backend/Backend.Specs/for_Timeline/when_recording_an_entry.cs @@ -0,0 +1,31 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Chronicle.Backend.Specs.for_Timeline; + +public class when_recording_an_entry : Specification +{ + const string EntryText = "Chronicle keeps the facts."; + IEventLog _eventLog; + Timeline _timeline; + TimelineId _timelineId; + AppendResult _result; + + void Establish() + { + _eventLog = Substitute.For(); + _eventLog + .Append(Arg.Any(), Arg.Any()) + .Returns(AppendResult.Success(CorrelationId.New(), 42)); + _timeline = new(_eventLog); + _timelineId = TimelineId.New(); + } + + async Task Because() => _result = await _timeline.Record(_timelineId, EntryText); + + [Fact] void should_return_the_append_result() => _result.SequenceNumber.Value.ShouldEqual(42UL); + [Fact] + async Task should_append_the_typed_event() => await _eventLog.Received(1).Append( + (EventSourceId)_timelineId, + Arg.Is(@event => @event.Text == EntryText)); +} diff --git a/Chronicle/Backend/Backend.csproj b/Chronicle/Backend/Backend.csproj new file mode 100644 index 00000000..7156bd6e --- /dev/null +++ b/Chronicle/Backend/Backend.csproj @@ -0,0 +1,10 @@ + + + Chronicle.Backend + $(DefaultItemExcludes);Backend.Specs/** + + + + + + diff --git a/Chronicle/Backend/ChronicleConfiguration.cs b/Chronicle/Backend/ChronicleConfiguration.cs new file mode 100644 index 00000000..43a69e14 --- /dev/null +++ b/Chronicle/Backend/ChronicleConfiguration.cs @@ -0,0 +1,12 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Chronicle; + +namespace Chronicle.Backend; + +static class ChronicleConfiguration +{ + public const string EventStore = "ChronicleBackend"; + public static readonly EventStoreNamespaceName Namespace = EventStoreNamespaceName.Default; +} diff --git a/Chronicle/Backend/ChronicleReadiness.cs b/Chronicle/Backend/ChronicleReadiness.cs new file mode 100644 index 00000000..b050dc76 --- /dev/null +++ b/Chronicle/Backend/ChronicleReadiness.cs @@ -0,0 +1,36 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Chronicle; +using Cratis.Chronicle.Registrations; + +namespace Chronicle.Backend; + +sealed class ChronicleReadiness(IEventStore eventStore) +{ + static readonly TimeSpan _registrationTimeout = TimeSpan.FromSeconds(5); + + public async Task GetUnavailableResult() + { + RegistrationOutcome outcome; + + try + { + outcome = await eventStore.WaitForRegistration(_registrationTimeout); + } + catch (TaskCanceledException) + { + return Results.Problem( + "Client artifact registration did not finish before the request deadline.", + statusCode: StatusCodes.Status503ServiceUnavailable, + title: "Chronicle is not ready"); + } + + return outcome.IsSuccess + ? null + : Results.Problem( + "Inspect the application and Chronicle logs before retrying the request.", + statusCode: StatusCodes.Status503ServiceUnavailable, + title: "Chronicle registration failed"); + } +} diff --git a/Chronicle/Backend/Program.cs b/Chronicle/Backend/Program.cs new file mode 100644 index 00000000..1a48e17c --- /dev/null +++ b/Chronicle/Backend/Program.cs @@ -0,0 +1,16 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Chronicle.Backend; + +var builder = WebApplication.CreateBuilder(args) + .AddCratisChronicle(options => options.EventStore = ChronicleConfiguration.EventStore); + +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +var app = builder.Build(); +app.UseCratisChronicle(); +app.MapTimelineEndpoints(); + +await app.RunAsync(); diff --git a/Chronicle/Backend/README.md b/Chronicle/Backend/README.md new file mode 100644 index 00000000..e9f18058 --- /dev/null +++ b/Chronicle/Backend/README.md @@ -0,0 +1,196 @@ +# Chronicle backend: append a fact, read its history + +> A deliberately small ASP.NET Core sample that shows Chronicle's event-log fundamentals without adding an application framework or a read side. + +**One event store. One default namespace. One typed event source. One immutable event.** + +| Setting | Value | +| --- | --- | +| Event store | `ChronicleBackend` | +| Namespace | `EventStoreNamespaceName.Default` | +| Event source | `TimelineId` (`EventSourceId`) | +| Event | `TimelineEntryRecorded` | +| API | ASP.NET Core minimal endpoints | + +## What you will build + +The API accepts a line of text for a timeline, appends a `TimelineEntryRecorded` event, and reads that timeline's event history directly from Chronicle. + +```mermaid +flowchart LR + caller[HTTP client] -->|POST entry| api[ASP.NET Core endpoints] + caller -->|GET history| api + api --> readiness[Registration readiness check] + readiness --> store[IEventStore] + api --> timeline[Timeline] + timeline -->|Append typed event| log[IEventLog] + timeline -->|Read by TimelineId| log + store --> client[Chronicle client] + log --> client + client -->|gRPC| kernel[Chronicle kernel] + kernel --> database[(ChronicleBackend
default namespace)] +``` + +The write and read paths use the same event log. There is no projection or mutable read model hiding the stored facts. + +## Prerequisites + +- The .NET SDK selected by the repository's `global.json` (the sample targets .NET 10). +- Docker Desktop or another Docker-compatible container runtime. +- Ports `35000` and `5095` available locally. +- A shell with `curl`. + +The Chronicle development image uses development credentials and is intended only for local learning. + +## Run the sample + +Run every command from the repository root. + +### 1. Start Chronicle + +```bash +docker run --rm --name chronicle-backend-sample \ + -p 35000:35000 \ + cratis/chronicle:latest-development +``` + +Leave that terminal running. Chronicle serves its client endpoint and development workbench on port `35000`. + +### 2. Restore and start the API + +In a second terminal: + +```bash +dotnet restore Chronicle/Backend/Backend.csproj +dotnet run --project Chronicle/Backend/Backend.csproj --no-restore --urls http://localhost:5095 +``` + +The application uses Chronicle's development connection defaults, names the event store `ChronicleBackend`, and leaves namespace resolution on `EventStoreNamespaceName.Default`. + +### 3. Inspect the sample metadata + +```bash +curl --silent http://localhost:5095/ +``` + +The response identifies the selected store, namespace, and endpoint templates. + +## Append an event + +Use a stable timeline identifier so the following history request addresses the same event source: + +```bash +TIMELINE_ID=7d9d3f76-0c2d-4a93-a67b-d8f8fb2bc941 + +curl --include \ + --request POST \ + --header 'Content-Type: application/json' \ + --data '{"text":"Chronicle keeps the facts."}' \ + "http://localhost:5095/api/timelines/${TIMELINE_ID}/entries" +``` + +Expected response: + +```http +HTTP/1.1 201 Created +Location: /api/timelines/7d9d3f76-0c2d-4a93-a67b-d8f8fb2bc941/history +Content-Type: application/json; charset=utf-8 + +{"timelineId":"7d9d3f76-0c2d-4a93-a67b-d8f8fb2bc941","sequenceNumber":0} +``` + +Chronicle owns the sequence number. A fresh event log starts at sequence number `0`; an existing local container can return a higher value. + +An empty or whitespace-only `text` value returns HTTP `400` and is not appended. + +## Read the event-source history + +```bash +curl --silent \ + "http://localhost:5095/api/timelines/${TIMELINE_ID}/history" +``` + +Expected response shape (the server-assigned `occurred` value varies): + +```json +[ + { + "sequenceNumber": 0, + "occurred": "2026-01-15T12:34:56.789Z", + "text": "Chronicle keeps the facts." + } +] +``` + +The history query filters the event log by the strongly typed `TimelineId` and the `TimelineEntryRecorded` event type. Events for another timeline do not appear. + +## Registration-aware behavior + +`UseCratisChronicle()` connects the client and starts automatic artifact discovery and registration. Before either data endpoint touches the event log, `ChronicleReadiness` awaits `WaitForRegistration()` for up to five seconds and checks the returned outcome. + +- Registration completed successfully: the request proceeds. +- Registration did not finish before the deadline: the API returns HTTP `503`. +- Registration completed with a failure: the API returns HTTP `503` and directs you to the application and Chronicle logs. + +This avoids treating a momentary connection flag as proof that registration finished. The registration outcome reports projection artifacts; this intentionally projection-free sample has none, but still waits for the registration round instead of racing startup. + +## Run the specs + +```bash +dotnet restore Chronicle/Backend/Backend.Specs/Backend.Specs.csproj +dotnet test Chronicle/Backend/Backend.Specs/Backend.Specs.csproj --no-restore +``` + +Expected summary: + +```text +Passed! - Failed: 0, Passed: 4, Skipped: 0, Total: 4 +``` + +The specs verify that `Timeline`: + +- appends `TimelineEntryRecorded` with the typed event-source identifier; +- returns Chronicle's append sequence number; +- queries history with the same typed identifier and event-type filter; and +- maps stored event content into the HTTP history shape. + +## Build only + +```bash +dotnet restore Chronicle/Backend/Backend.csproj +dotnet build Chronicle/Backend/Backend.csproj --no-restore +``` + +## Code tour + +| File | Purpose | +| --- | --- | +| `Program.cs` | Configures the named Chronicle event store and ASP.NET Core pipeline. | +| `TimelineId.cs` | Gives the event source a domain-specific `EventSourceId` identity. | +| `TimelineEntryRecorded.cs` | Defines the immutable, past-tense event. | +| `Timeline.cs` | Encapsulates direct event-log append and history operations. | +| `ChronicleReadiness.cs` | Turns registration completion or failure into explicit endpoint behavior. | +| `TimelineEndpoints.cs` | Exposes append, history, and sample metadata over HTTP. | +| `Backend.Specs/` | Specifies the Chronicle-facing behavior without external infrastructure. | + +## Learning points + +1. **Name the store at composition time.** `AddCratisChronicle()` selects `ChronicleBackend`; the default namespace resolver keeps the sample in one namespace. +2. **Type event-source identities.** `TimelineId` prevents unrelated `Guid` values from being used accidentally inside the Chronicle boundary. +3. **Write facts in past tense.** `TimelineEntryRecorded` describes something that happened and remains immutable. +4. **Use the event log directly when teaching the event log.** The append returns Chronicle's result, while history reads the stored events for one event source. +5. **Observe registration rather than guessing readiness.** Requests fail clearly with `503` instead of racing client startup. +6. **Keep the first sample focused.** Nothing obscures the relationship between HTTP, the Chronicle client, and the event log. + +## Intentional limitations + +This is a learning sample, not a production template. + +- It has no authentication, authorization, rate limiting, maximum text length, or production secret/configuration handling. +- It does not apply optimistic concurrency or idempotency, so repeated POST requests append repeated facts. +- History is unpaged and reads directly from the event log; large histories need paging or a purpose-built read model. +- The specs isolate `Timeline` with `IEventLog`; they do not start Chronicle or exercise HTTP. The currently pinned in-process testing package does not provide a standalone runtime closure for this intentionally Arc-free sample. +- The sample does not include Arc, React, projections, tenancy, cross-store messaging, reactors, reducers, or deployment configuration. +- The development container and default connection settings are not production guidance. + +Those omissions are deliberate. Add each concern only after you understand this append-and-history path. diff --git a/Chronicle/Backend/Timeline.cs b/Chronicle/Backend/Timeline.cs new file mode 100644 index 00000000..e967dfc0 --- /dev/null +++ b/Chronicle/Backend/Timeline.cs @@ -0,0 +1,30 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Chronicle.Events; +using Cratis.Chronicle.EventSequences; + +namespace Chronicle.Backend; + +sealed class Timeline(IEventLog eventLog) +{ + public async Task Record(TimelineId timelineId, string text) => + await eventLog.Append(timelineId, new TimelineEntryRecorded(text)); + + public async Task> GetHistory(TimelineId timelineId) + { + var events = await eventLog.GetForEventSourceIdAndEventTypes( + timelineId, + [typeof(TimelineEntryRecorded).GetEventType()]); + + return + [ + .. events.Select(appendedEvent => new TimelineHistoryEntry( + appendedEvent.Context.SequenceNumber.Value, + appendedEvent.Context.Occurred, + ((TimelineEntryRecorded)appendedEvent.Content).Text)) + ]; + } +} + +sealed record TimelineHistoryEntry(ulong SequenceNumber, DateTimeOffset Occurred, string Text); diff --git a/Chronicle/Backend/TimelineEndpoints.cs b/Chronicle/Backend/TimelineEndpoints.cs new file mode 100644 index 00000000..83d5b1f4 --- /dev/null +++ b/Chronicle/Backend/TimelineEndpoints.cs @@ -0,0 +1,79 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Chronicle.Backend; + +static class TimelineEndpoints +{ + public static void MapTimelineEndpoints(this WebApplication app) + { + app.MapGet("/", () => Results.Ok(new SampleDescription( + ChronicleConfiguration.EventStore, + ChronicleConfiguration.Namespace.Value, + "/api/timelines/{timelineId}/entries", + "/api/timelines/{timelineId}/history"))); + + var timelines = app.MapGroup("/api/timelines/{timelineId:guid}"); + timelines.MapPost("/entries", RecordEntry); + timelines.MapGet("/history", GetHistory); + } + + static async Task RecordEntry( + Guid timelineId, + RecordTimelineEntryRequest request, + ChronicleReadiness readiness, + Timeline timeline) + { + var unavailable = await readiness.GetUnavailableResult(); + + if (unavailable is not null) + { + return unavailable; + } + + if (string.IsNullOrWhiteSpace(request.Text)) + { + return Results.ValidationProblem(new Dictionary + { + [nameof(request.Text)] = ["Text is required."] + }); + } + + TimelineId typedTimelineId = timelineId; + var appendResult = await timeline.Record(typedTimelineId, request.Text.Trim()); + + if (!appendResult.IsSuccess) + { + return Results.Problem( + "Inspect the application and Chronicle logs before retrying the request.", + statusCode: StatusCodes.Status503ServiceUnavailable, + title: "Chronicle rejected the event"); + } + + return Results.Created( + $"/api/timelines/{timelineId:D}/history", + new TimelineEntryAccepted(timelineId, appendResult.SequenceNumber.Value)); + } + + static async Task GetHistory( + Guid timelineId, + ChronicleReadiness readiness, + Timeline timeline) + { + var unavailable = await readiness.GetUnavailableResult(); + + if (unavailable is not null) + { + return unavailable; + } + + TimelineId typedTimelineId = timelineId; + var history = await timeline.GetHistory(typedTimelineId); + + return Results.Ok(history); + } +} + +sealed record RecordTimelineEntryRequest(string Text); +sealed record TimelineEntryAccepted(Guid TimelineId, ulong SequenceNumber); +sealed record SampleDescription(string EventStore, string Namespace, string AppendEndpoint, string HistoryEndpoint); diff --git a/Chronicle/Backend/TimelineEntryRecorded.cs b/Chronicle/Backend/TimelineEntryRecorded.cs new file mode 100644 index 00000000..08eafef2 --- /dev/null +++ b/Chronicle/Backend/TimelineEntryRecorded.cs @@ -0,0 +1,13 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Chronicle.Events; + +namespace Chronicle.Backend; + +/// +/// Records one immutable entry in a timeline's history. +/// +/// The text captured in the timeline. +[EventType] +public record TimelineEntryRecorded(string Text); diff --git a/Chronicle/Backend/TimelineId.cs b/Chronicle/Backend/TimelineId.cs new file mode 100644 index 00000000..8165172e --- /dev/null +++ b/Chronicle/Backend/TimelineId.cs @@ -0,0 +1,31 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Chronicle.Events; + +namespace Chronicle.Backend; + +/// +/// Represents the unique identifier of a timeline event source. +/// +/// The underlying identifier. +public record TimelineId(Guid Value) : EventSourceId(Value) +{ + /// + /// Gets the identifier used when no timeline has been selected. + /// + public static readonly TimelineId NotSet = new(Guid.Empty); + + /// + /// Converts a to a . + /// + /// The value to convert. + /// The strongly typed timeline identifier. + public static implicit operator TimelineId(Guid value) => new(value); + + /// + /// Creates a new timeline identifier. + /// + /// A new . + public static TimelineId New() => new(Guid.NewGuid()); +} From acb3d6afcc8fe027e05cb62de732050055d1d806 Mon Sep 17 00:00:00 2001 From: woksin Date: Sun, 23 Aug 2026 01:08:52 +0200 Subject: [PATCH 02/12] Add focused Chronicle processing sample --- Chronicle/Processing/CompletionSummary.cs | 25 ++++ .../Processing/CompletionSummaryReactor.cs | 32 +++++ Chronicle/Processing/Events.cs | 37 ++++++ Chronicle/Processing/PlanOutcome.cs | 25 ++++ .../Processing.Specs/GlobalUsings.cs | 8 ++ .../Processing.Specs/Processing.Specs.csproj | 21 ++++ .../when_completing_a_work_item.cs | 15 +++ .../when_opening_a_work_item.cs | 14 +++ .../when_recording_progress.cs | 30 +++++ Chronicle/Processing/Processing.csproj | 16 +++ Chronicle/Processing/Program.cs | 69 ++++++++++ .../Processing/Properties/launchSettings.json | 14 +++ Chronicle/Processing/README.md | 119 ++++++++++++++++++ Chronicle/Processing/WorkItemDetails.cs | 15 +++ Chronicle/Processing/WorkItemId.cs | 31 +++++ Chronicle/Processing/WorkItemProgress.cs | 75 +++++++++++ Chronicle/Processing/WorkItemTitle.cs | 25 ++++ Chronicle/Processing/WorkPoints.cs | 25 ++++ 18 files changed, 596 insertions(+) create mode 100644 Chronicle/Processing/CompletionSummary.cs create mode 100644 Chronicle/Processing/CompletionSummaryReactor.cs create mode 100644 Chronicle/Processing/Events.cs create mode 100644 Chronicle/Processing/PlanOutcome.cs create mode 100644 Chronicle/Processing/Processing.Specs/GlobalUsings.cs create mode 100644 Chronicle/Processing/Processing.Specs/Processing.Specs.csproj create mode 100644 Chronicle/Processing/Processing.Specs/for_CompletionSummaryReactor/when_completing_a_work_item.cs create mode 100644 Chronicle/Processing/Processing.Specs/for_WorkItemDetails/when_opening_a_work_item.cs create mode 100644 Chronicle/Processing/Processing.Specs/for_WorkItemProgress/when_recording_progress.cs create mode 100644 Chronicle/Processing/Processing.csproj create mode 100644 Chronicle/Processing/Program.cs create mode 100644 Chronicle/Processing/Properties/launchSettings.json create mode 100644 Chronicle/Processing/README.md create mode 100644 Chronicle/Processing/WorkItemDetails.cs create mode 100644 Chronicle/Processing/WorkItemId.cs create mode 100644 Chronicle/Processing/WorkItemProgress.cs create mode 100644 Chronicle/Processing/WorkItemTitle.cs create mode 100644 Chronicle/Processing/WorkPoints.cs diff --git a/Chronicle/Processing/CompletionSummary.cs b/Chronicle/Processing/CompletionSummary.cs new file mode 100644 index 00000000..4e15c929 --- /dev/null +++ b/Chronicle/Processing/CompletionSummary.cs @@ -0,0 +1,25 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Concepts; + +namespace Processing; + +/// +/// Represents a summary of completed work against its plan. +/// +/// The underlying summary. +public record CompletionSummary(string Value) : ConceptAs(Value) +{ + /// + /// Represents an unset completion summary. + /// + public static readonly CompletionSummary NotSet = new(string.Empty); + + /// + /// Converts a string to a completion summary. + /// + /// The value to convert. + /// The converted completion summary. + public static implicit operator CompletionSummary(string value) => new(value); +} diff --git a/Chronicle/Processing/CompletionSummaryReactor.cs b/Chronicle/Processing/CompletionSummaryReactor.cs new file mode 100644 index 00000000..75960f7c --- /dev/null +++ b/Chronicle/Processing/CompletionSummaryReactor.cs @@ -0,0 +1,32 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Chronicle.Reactors; + +namespace Processing; + +/// +/// Produces a deterministic summary when a work item completes. +/// +/// +/// The result depends only on the triggering event, while prevents +/// the follow-up fact from being repeated during replay. Chronicle handles the returned event without +/// direct event-log access in the reactor. +/// +public class CompletionSummaryReactor : IReactor +{ + /// + /// Summarizes the completed work against its plan. + /// + /// The completion event. + /// The deterministic completion summary. + [OnceOnly] + public CompletionSummarized Completed(WorkItemCompleted @event) + { + var metPlan = @event.CompletedPoints.Value >= @event.PlannedPoints.Value; + var result = metPlan ? "met" : "did not meet"; + return new( + $"Completed {@event.CompletedPoints.Value} of {@event.PlannedPoints.Value} planned points and {result} the plan.", + metPlan ? PlanOutcome.Met : PlanOutcome.Missed); + } +} diff --git a/Chronicle/Processing/Events.cs b/Chronicle/Processing/Events.cs new file mode 100644 index 00000000..7f0058b7 --- /dev/null +++ b/Chronicle/Processing/Events.cs @@ -0,0 +1,37 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Chronicle.Events; + +namespace Processing; + +/// +/// The event that records the opening plan for a work item. +/// +/// The work item title. +/// The number of points planned for the work item. +[EventType] +public record WorkItemOpened(WorkItemTitle Title, WorkPoints PlannedPoints); + +/// +/// The event that records completed points against a work item. +/// +/// The number of points completed by this update. +[EventType] +public record ProgressRecorded(WorkPoints Points); + +/// +/// The event that records the final delivery totals for a work item. +/// +/// The final number of completed points. +/// The number of points that were planned. +[EventType] +public record WorkItemCompleted(WorkPoints CompletedPoints, WorkPoints PlannedPoints); + +/// +/// The event produced by the completion reactor with a deterministic delivery summary. +/// +/// The human-readable delivery summary. +/// Whether the completed points met or exceeded the plan. +[EventType] +public record CompletionSummarized(CompletionSummary Summary, PlanOutcome Outcome); diff --git a/Chronicle/Processing/PlanOutcome.cs b/Chronicle/Processing/PlanOutcome.cs new file mode 100644 index 00000000..5f205f54 --- /dev/null +++ b/Chronicle/Processing/PlanOutcome.cs @@ -0,0 +1,25 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Processing; + +/// +/// Describes whether completed work met its plan. +/// +public enum PlanOutcome +{ + /// + /// No outcome has been recorded. + /// + NotSet = 0, + + /// + /// Completed work did not meet the plan. + /// + Missed = 1, + + /// + /// Completed work met or exceeded the plan. + /// + Met = 2 +} diff --git a/Chronicle/Processing/Processing.Specs/GlobalUsings.cs b/Chronicle/Processing/Processing.Specs/GlobalUsings.cs new file mode 100644 index 00000000..562a4d87 --- /dev/null +++ b/Chronicle/Processing/Processing.Specs/GlobalUsings.cs @@ -0,0 +1,8 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +global using Cratis.Chronicle.Events; +global using Cratis.Chronicle.Projections.ModelBound; +global using Cratis.Specifications; +global using Processing; +global using Xunit; diff --git a/Chronicle/Processing/Processing.Specs/Processing.Specs.csproj b/Chronicle/Processing/Processing.Specs/Processing.Specs.csproj new file mode 100644 index 00000000..815c0004 --- /dev/null +++ b/Chronicle/Processing/Processing.Specs/Processing.Specs.csproj @@ -0,0 +1,21 @@ + + + Processing.Specs + true + false + + + + + + + + + + + + + all + + + diff --git a/Chronicle/Processing/Processing.Specs/for_CompletionSummaryReactor/when_completing_a_work_item.cs b/Chronicle/Processing/Processing.Specs/for_CompletionSummaryReactor/when_completing_a_work_item.cs new file mode 100644 index 00000000..f91bda12 --- /dev/null +++ b/Chronicle/Processing/Processing.Specs/for_CompletionSummaryReactor/when_completing_a_work_item.cs @@ -0,0 +1,15 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Processing.Specs.for_CompletionSummaryReactor; + +public class when_completing_a_work_item : Specification +{ + CompletionSummarized _result; + + void Because() => _result = new CompletionSummaryReactor().Completed(new WorkItemCompleted(8, 8)); + + [Fact] + void should_produce_the_expected_summary() => + _result.ShouldEqual(new CompletionSummarized("Completed 8 of 8 planned points and met the plan.", PlanOutcome.Met)); +} diff --git a/Chronicle/Processing/Processing.Specs/for_WorkItemDetails/when_opening_a_work_item.cs b/Chronicle/Processing/Processing.Specs/for_WorkItemDetails/when_opening_a_work_item.cs new file mode 100644 index 00000000..72ed8ab0 --- /dev/null +++ b/Chronicle/Processing/Processing.Specs/for_WorkItemDetails/when_opening_a_work_item.cs @@ -0,0 +1,14 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Processing.Specs.for_WorkItemDetails; + +public class when_opening_a_work_item : Specification +{ + bool _isBoundToOpeningEvent; + + void Because() => + _isBoundToOpeningEvent = typeof(WorkItemDetails).IsDefined(typeof(FromEventAttribute), false); + + [Fact] void should_bind_the_model_to_the_opening_event() => _isBoundToOpeningEvent.ShouldBeTrue(); +} diff --git a/Chronicle/Processing/Processing.Specs/for_WorkItemProgress/when_recording_progress.cs b/Chronicle/Processing/Processing.Specs/for_WorkItemProgress/when_recording_progress.cs new file mode 100644 index 00000000..6dd4a906 --- /dev/null +++ b/Chronicle/Processing/Processing.Specs/for_WorkItemProgress/when_recording_progress.cs @@ -0,0 +1,30 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Processing.Specs.for_WorkItemProgress; + +public class when_recording_progress : Specification +{ + readonly WorkItemId _workItemId = WorkItemId.New(); + WorkItemProgress _result; + + void Because() + { + var reducer = new WorkItemProgressReducer(); + _result = reducer.Opened(new WorkItemOpened("Prepare release", 10), null, ContextFor(0)); + _result = reducer.Recorded(new ProgressRecorded(4), _result, ContextFor(1)); + _result = reducer.Recorded(new ProgressRecorded(5), _result, ContextFor(2)); + _result = reducer.Recorded(new ProgressRecorded(3), _result, ContextFor(3)); + } + + [Fact] + void should_fold_every_update_into_the_capped_progress() => + _result.ShouldEqual(new WorkItemProgress(_workItemId, 10, 10, WorkPoints.NotSet, new EventSequenceNumber(3))); + + EventContext ContextFor(ulong sequenceNumber) => + EventContext.Empty with + { + EventSourceId = _workItemId, + SequenceNumber = new EventSequenceNumber(sequenceNumber) + }; +} diff --git a/Chronicle/Processing/Processing.csproj b/Chronicle/Processing/Processing.csproj new file mode 100644 index 00000000..23b819fc --- /dev/null +++ b/Chronicle/Processing/Processing.csproj @@ -0,0 +1,16 @@ + + + Processing + + + + + + + + + + + + + diff --git a/Chronicle/Processing/Program.cs b/Chronicle/Processing/Program.cs new file mode 100644 index 00000000..01e32e81 --- /dev/null +++ b/Chronicle/Processing/Program.cs @@ -0,0 +1,69 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Chronicle; +using Cratis.Chronicle.Events; +using Cratis.Chronicle.Observation; +using Processing; + +var builder = WebApplication.CreateBuilder(args) + .AddCratisChronicle(options => options.EventStore = "ProcessingSample"); + +var app = builder.Build(); +app.UseCratisChronicle(); + +app.MapGet("/", () => Results.Ok(new +{ + Sample = "Chronicle focused processing", + Run = "POST /processing/run" +})); + +app.MapPost("/processing/run", async (IEventStore eventStore) => +{ + var workItemId = WorkItemId.New(); + var appendResult = await eventStore.EventLog.AppendMany( + workItemId, + [ + new WorkItemOpened("Publish the focused processing sample", 8), + new ProgressRecorded(3), + new ProgressRecorded(2), + new ProgressRecorded(3), + new WorkItemCompleted(8, 8) + ]); + + if (!appendResult.IsSuccess) + { + return Results.Problem("Chronicle rejected the sample event batch."); + } + + var completion = await appendResult.WaitForCompletion(TimeSpan.FromSeconds(10)); + if (!completion.IsSuccess) + { + return Results.Problem("One or more Chronicle observers failed while processing the sample batch."); + } + + var details = await eventStore.ReadModels.GetInstanceById((EventSourceId)workItemId); + var progress = await eventStore.ReadModels.GetInstanceById((EventSourceId)workItemId); + var stream = await eventStore.EventLog.GetFromSequenceNumber(EventSequenceNumber.First, workItemId); + var summary = stream.Select(_ => _.Content).OfType().SingleOrDefault(); + + if (summary is null) + { + return Results.Problem("The completion reactor did not produce its summary event."); + } + + return Results.Ok(new + { + WorkItemId = workItemId.Value, + Details = details, + Progress = progress, + ReactorOutput = new + { + Summary = summary.Summary.Value, + MetPlan = summary.Outcome == PlanOutcome.Met + }, + WaitedForProcessing = true + }); +}); + +await app.RunAsync(); diff --git a/Chronicle/Processing/Properties/launchSettings.json b/Chronicle/Processing/Properties/launchSettings.json new file mode 100644 index 00000000..b2b6ed86 --- /dev/null +++ b/Chronicle/Processing/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "Processing": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5074", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/Chronicle/Processing/README.md b/Chronicle/Processing/README.md new file mode 100644 index 00000000..7855ec57 --- /dev/null +++ b/Chronicle/Processing/README.md @@ -0,0 +1,119 @@ +
+ +# Chronicle focused processing + +### One event stream. Three processing styles. No sleeps. + +**Model-bound projection · Typed reducer · Deterministic reactor · ASP.NET Core** + +
+ +--- + +This backend-only sample keeps Chronicle processing small and visible. One HTTP request appends a work item's history, waits for the affected observers, and returns the resulting views and reactor output. + +## Architecture + +```mermaid +flowchart LR + Client[HTTP client] -->|POST /processing/run| API[Minimal API] + API -->|AppendMany| Log[(Chronicle event log)] + + Log -->|WorkItemOpened| Projection[Model-bound projection] + Projection --> Details[(WorkItemDetails)] + + Log -->|Opened + ProgressRecorded| Reducer[Typed reducer] + Reducer --> Progress[(WorkItemProgress)] + + Log -->|WorkItemCompleted| Reactor[Deterministic reactor] + Reactor -->|returns CompletionSummarized| Log + + API -. WaitForCompletion .-> Projection + API -. WaitForCompletion .-> Reducer + API -. WaitForCompletion .-> Reactor +``` + +| Style | Artifact | What it shows | +| --- | --- | --- | +| Model-bound projection | `WorkItemDetails` | `[FromEvent]` and AutoMap for a direct event-to-view mapping. | +| Typed reducer | `WorkItemProgressReducer` | Prior-state accumulation, coordinated calculations, and immutable `with` transitions. | +| Deterministic reactor | `CompletionSummaryReactor` | A stateless follow-up event derived only from the triggering event. | + +The domain uses `WorkItemId : EventSourceId` for stream identity and small `ConceptAs` values for titles, work points, and completion summaries. This keeps events and read models strongly typed without distracting from the processing flow. + +## Prerequisites + +- .NET 10 SDK, selected by the repository's `global.json`. +- Docker with Compose support. +- The existing Chronicle development container, which provides the local kernel and read-model sink. + +The projects use only package versions managed centrally by this repository. + +## Run it + +From the repository root, start Chronicle: + +```bash +docker compose -f Chronicle/Quickstart/docker-compose.yml up -d chronicle +``` + +Start the sample API: + +```bash +dotnet run --project Chronicle/Processing/Processing.csproj +``` + +Then trigger the flow: + +```bash +curl --request POST http://localhost:5074/processing/run +``` + +The response contains a generated work item id and these results: + +```json +{ + "details": { + "title": "Publish the focused processing sample", + "plannedPoints": 8 + }, + "progress": { + "completedPoints": 8, + "remainingPoints": 0 + }, + "reactorOutput": { + "summary": "Completed 8 of 8 planned points and met the plan.", + "metPlan": true + }, + "waitedForProcessing": true +} +``` + +The endpoint uses `WaitForCompletion` with a ten-second deadline before reading materialized state. It does not use `Thread.Sleep`, `Task.Delay`, or timing guesses. + +## Build and test + +The sample is intentionally not added to the root solution. Target its projects directly: + +```bash +dotnet build Chronicle/Processing/Processing.csproj +dotnet test Chronicle/Processing/Processing.Specs/Processing.Specs.csproj +``` + +The three focused specifications show the projection binding, reducer fold, and deterministic reactor result without introducing external infrastructure. + +## Learning points + +- Use `EventSourceId` for stream identities and `ConceptAs` for meaningful domain values. +- Start with model-bound attributes when event and view properties line up. +- Choose a reducer when the next state genuinely depends on prior state. +- Keep reactors stateless; use event data directly and return follow-up events instead of injecting `IEventLog`. +- Await an observable processing boundary when a request truly needs read-after-write consistency. + +## Limitations + +- **Materialization is asynchronous.** An append can be durable before projections and reducers catch up. This endpoint waits because it immediately reads both views; most write paths should remain asynchronous. +- The ten-second deadline is a sample choice, not a production service objective. +- The request appends fixed demonstration data. It does not cover commands, validation, authorization, or user input. +- The default Chronicle development connection is local-only. +- There is no Arc, React, tenancy, cross-store flow, or frontend. diff --git a/Chronicle/Processing/WorkItemDetails.cs b/Chronicle/Processing/WorkItemDetails.cs new file mode 100644 index 00000000..f6c4dbdc --- /dev/null +++ b/Chronicle/Processing/WorkItemDetails.cs @@ -0,0 +1,15 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Chronicle.Projections.ModelBound; + +namespace Processing; + +/// +/// Provides the stable identity and plan for a work item. +/// +/// The event source identifier. +/// The work item title. +/// The number of points planned for the work item. +[FromEvent] +public record WorkItemDetails(WorkItemId Id, WorkItemTitle Title, WorkPoints PlannedPoints); diff --git a/Chronicle/Processing/WorkItemId.cs b/Chronicle/Processing/WorkItemId.cs new file mode 100644 index 00000000..32f91861 --- /dev/null +++ b/Chronicle/Processing/WorkItemId.cs @@ -0,0 +1,31 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Chronicle.Events; + +namespace Processing; + +/// +/// Represents the identity of a work item. +/// +/// The underlying identifier. +public record WorkItemId(Guid Value) : EventSourceId(Value) +{ + /// + /// Represents an unset work item identity. + /// + public static readonly WorkItemId NotSet = new(Guid.Empty); + + /// + /// Converts a to a work item identity. + /// + /// The value to convert. + /// The converted work item identity. + public static implicit operator WorkItemId(Guid value) => new(value); + + /// + /// Creates a new work item identity. + /// + /// A new work item identity. + public static WorkItemId New() => new(Guid.NewGuid()); +} diff --git a/Chronicle/Processing/WorkItemProgress.cs b/Chronicle/Processing/WorkItemProgress.cs new file mode 100644 index 00000000..ab36b6ad --- /dev/null +++ b/Chronicle/Processing/WorkItemProgress.cs @@ -0,0 +1,75 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Chronicle.Events; +using Cratis.Chronicle.Reducers; + +namespace Processing; + +/// +/// Represents progress accumulated from a work item's event stream. +/// +/// The event source identifier. +/// The number of points planned for the work item. +/// The accumulated completed points, capped at the plan. +/// The number of planned points that remain. +/// The sequence number of the last event processed. +public record WorkItemProgress( + WorkItemId Id, + WorkPoints PlannedPoints, + WorkPoints CompletedPoints, + WorkPoints RemainingPoints, + EventSequenceNumber LastSequenceNumber); + +/// +/// Folds work item events into accumulated progress. +/// +/// +/// A reducer is required because capped completion and remaining work both depend on prior state; +/// projection attributes and fluent setters cannot express this coordinated transition. +/// +public class WorkItemProgressReducer : IReducerFor +{ + /// + /// Initializes progress from the work item's plan. + /// + /// The event that opened the work item. + /// The current state, which is not used for the opening event. + /// The event context. + /// The initialized work item progress. + public WorkItemProgress Opened(WorkItemOpened @event, WorkItemProgress? current, EventContext context) + { + var workItemId = (WorkItemId)Guid.Parse(context.EventSourceId); + return new( + workItemId, + @event.PlannedPoints, + WorkPoints.NotSet, + @event.PlannedPoints, + context.SequenceNumber); + } + + /// + /// Applies a progress update to the prior accumulated state. + /// + /// The progress update. + /// The current accumulated state, or before opening. + /// The event context. + /// The next accumulated state, or when no opening event exists. + public WorkItemProgress? Recorded(ProgressRecorded @event, WorkItemProgress? current, EventContext context) + { + if (current is null) + { + return null; + } + + var completedPoints = Math.Min( + current.PlannedPoints.Value, + current.CompletedPoints.Value + @event.Points.Value); + return current with + { + CompletedPoints = completedPoints, + RemainingPoints = current.PlannedPoints.Value - completedPoints, + LastSequenceNumber = context.SequenceNumber + }; + } +} diff --git a/Chronicle/Processing/WorkItemTitle.cs b/Chronicle/Processing/WorkItemTitle.cs new file mode 100644 index 00000000..ea61d283 --- /dev/null +++ b/Chronicle/Processing/WorkItemTitle.cs @@ -0,0 +1,25 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Concepts; + +namespace Processing; + +/// +/// Represents the title of a work item. +/// +/// The underlying title. +public record WorkItemTitle(string Value) : ConceptAs(Value) +{ + /// + /// Represents an unset work item title. + /// + public static readonly WorkItemTitle NotSet = new(string.Empty); + + /// + /// Converts a string to a work item title. + /// + /// The value to convert. + /// The converted work item title. + public static implicit operator WorkItemTitle(string value) => new(value); +} diff --git a/Chronicle/Processing/WorkPoints.cs b/Chronicle/Processing/WorkPoints.cs new file mode 100644 index 00000000..0945f581 --- /dev/null +++ b/Chronicle/Processing/WorkPoints.cs @@ -0,0 +1,25 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Concepts; + +namespace Processing; + +/// +/// Represents an amount of work measured in points. +/// +/// The underlying number of points. +public record WorkPoints(int Value) : ConceptAs(Value) +{ + /// + /// Represents an unset amount of work. + /// + public static readonly WorkPoints NotSet = new(0); + + /// + /// Converts an integer to work points. + /// + /// The value to convert. + /// The converted work points. + public static implicit operator WorkPoints(int value) => new(value); +} From e4e41e7062fd3dcb8500192b12d31fc9037afbce Mon Sep 17 00:00:00 2001 From: woksin Date: Sun, 23 Aug 2026 01:30:03 +0200 Subject: [PATCH 03/12] Add Chronicle cross-store outbox sample --- Chronicle/CrossStore/ApiModels.cs | 20 ++ .../CrossStore.Specs/CrossStore.Specs.csproj | 21 ++ .../CrossStore.Specs/GlobalUsings.Specs.cs | 6 + .../when_translating_an_order_contract.cs | 15 ++ .../when_inspecting_the_contract.cs | 19 ++ Chronicle/CrossStore/CrossStore.csproj | 16 ++ Chronicle/CrossStore/DomainValues.cs | 101 ++++++++ Chronicle/CrossStore/Events.cs | 31 +++ Chronicle/CrossStore/FulfillmentOrder.cs | 15 ++ .../FulfillmentTranslationReactor.cs | 27 +++ Chronicle/CrossStore/OrderId.cs | 50 ++++ Chronicle/CrossStore/Program.cs | 77 ++++++ Chronicle/CrossStore/README.md | 229 ++++++++++++++++++ Chronicle/CrossStore/StoreNames.cs | 31 +++ 14 files changed, 658 insertions(+) create mode 100644 Chronicle/CrossStore/ApiModels.cs create mode 100644 Chronicle/CrossStore/CrossStore.Specs/CrossStore.Specs.csproj create mode 100644 Chronicle/CrossStore/CrossStore.Specs/GlobalUsings.Specs.cs create mode 100644 Chronicle/CrossStore/CrossStore.Specs/for_FulfillmentTranslationReactor/when_translating_an_order_contract.cs create mode 100644 Chronicle/CrossStore/CrossStore.Specs/for_OrderRequestedForFulfillment/when_inspecting_the_contract.cs create mode 100644 Chronicle/CrossStore/CrossStore.csproj create mode 100644 Chronicle/CrossStore/DomainValues.cs create mode 100644 Chronicle/CrossStore/Events.cs create mode 100644 Chronicle/CrossStore/FulfillmentOrder.cs create mode 100644 Chronicle/CrossStore/FulfillmentTranslationReactor.cs create mode 100644 Chronicle/CrossStore/OrderId.cs create mode 100644 Chronicle/CrossStore/Program.cs create mode 100644 Chronicle/CrossStore/README.md create mode 100644 Chronicle/CrossStore/StoreNames.cs diff --git a/Chronicle/CrossStore/ApiModels.cs b/Chronicle/CrossStore/ApiModels.cs new file mode 100644 index 00000000..18bd351b --- /dev/null +++ b/Chronicle/CrossStore/ApiModels.cs @@ -0,0 +1,20 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace CrossStore; + +/// +/// Describes the source-side order input. +/// +/// The source-owned buyer reference. +/// The ordered product. +/// The ordered quantity. +public record PlaceOrderRequest(string Buyer, string Sku, int Quantity); + +/// +/// Describes an accepted asynchronous cross-store flow. +/// +/// The shared correlation identity carried by event context. +/// The event store that owns order placement. +/// The event store that owns fulfillment. +public record PlaceOrderAccepted(Guid OrderId, string SourceEventStore, string TargetEventStore); diff --git a/Chronicle/CrossStore/CrossStore.Specs/CrossStore.Specs.csproj b/Chronicle/CrossStore/CrossStore.Specs/CrossStore.Specs.csproj new file mode 100644 index 00000000..3af8cded --- /dev/null +++ b/Chronicle/CrossStore/CrossStore.Specs/CrossStore.Specs.csproj @@ -0,0 +1,21 @@ + + + CrossStore.Specs + true + false + + + + + + + + + + + + + all + + + diff --git a/Chronicle/CrossStore/CrossStore.Specs/GlobalUsings.Specs.cs b/Chronicle/CrossStore/CrossStore.Specs/GlobalUsings.Specs.cs new file mode 100644 index 00000000..0be11df5 --- /dev/null +++ b/Chronicle/CrossStore/CrossStore.Specs/GlobalUsings.Specs.cs @@ -0,0 +1,6 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +global using Cratis.Specifications; +global using CrossStore; +global using Xunit; diff --git a/Chronicle/CrossStore/CrossStore.Specs/for_FulfillmentTranslationReactor/when_translating_an_order_contract.cs b/Chronicle/CrossStore/CrossStore.Specs/for_FulfillmentTranslationReactor/when_translating_an_order_contract.cs new file mode 100644 index 00000000..fba5fb41 --- /dev/null +++ b/Chronicle/CrossStore/CrossStore.Specs/for_FulfillmentTranslationReactor/when_translating_an_order_contract.cs @@ -0,0 +1,15 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace CrossStore.Specs.for_FulfillmentTranslationReactor; + +public class when_translating_an_order_contract : Specification +{ + FulfillmentOrderReceived _result; + + void Because() => _result = new FulfillmentTranslationReactor().Requested(new("SKU-42", 3)); + + [Fact] void should_translate_to_a_target_owned_fact() => _result.ShouldBeOfExactType(); + [Fact] void should_translate_the_product_identifier() => _result.Sku.ShouldEqual(new FulfillmentSku("SKU-42")); + [Fact] void should_translate_the_quantity() => _result.Quantity.ShouldEqual(new UnitsToFulfill(3)); +} diff --git a/Chronicle/CrossStore/CrossStore.Specs/for_OrderRequestedForFulfillment/when_inspecting_the_contract.cs b/Chronicle/CrossStore/CrossStore.Specs/for_OrderRequestedForFulfillment/when_inspecting_the_contract.cs new file mode 100644 index 00000000..67b20d0c --- /dev/null +++ b/Chronicle/CrossStore/CrossStore.Specs/for_OrderRequestedForFulfillment/when_inspecting_the_contract.cs @@ -0,0 +1,19 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace CrossStore.Specs.for_OrderRequestedForFulfillment; + +public class when_inspecting_the_contract : Specification +{ + string[] _propertyNames; + + void Because() => _propertyNames = + [ + .. typeof(OrderRequestedForFulfillment) + .GetProperties() + .Select(_ => _.Name) + ]; + + [Fact] void should_include_only_fulfillment_data() => _propertyNames.ShouldContainOnly(nameof(OrderRequestedForFulfillment.Sku), nameof(OrderRequestedForFulfillment.Quantity)); + [Fact] void should_not_expose_the_source_owned_buyer() => _propertyNames.ShouldNotContain(nameof(OrderPlaced.Buyer)); +} diff --git a/Chronicle/CrossStore/CrossStore.csproj b/Chronicle/CrossStore/CrossStore.csproj new file mode 100644 index 00000000..dee86581 --- /dev/null +++ b/Chronicle/CrossStore/CrossStore.csproj @@ -0,0 +1,16 @@ + + + CrossStore + + + + + + + + + + + + + diff --git a/Chronicle/CrossStore/DomainValues.cs b/Chronicle/CrossStore/DomainValues.cs new file mode 100644 index 00000000..3e158e70 --- /dev/null +++ b/Chronicle/CrossStore/DomainValues.cs @@ -0,0 +1,101 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Concepts; + +namespace CrossStore; + +/// +/// Represents a source-owned buyer reference. +/// +/// The underlying reference. +public record BuyerReference(string Value) : ConceptAs(Value) +{ + /// + /// Represents an unset buyer reference. + /// + public static readonly BuyerReference NotSet = new(string.Empty); + + /// + /// Converts a string to a buyer reference. + /// + /// The value to convert. + /// The converted buyer reference. + public static implicit operator BuyerReference(string value) => new(value); +} + +/// +/// Represents a product identifier in the orders contract. +/// +/// The underlying stock-keeping unit. +public record ProductSku(string Value) : ConceptAs(Value) +{ + /// + /// Represents an unset product identifier. + /// + public static readonly ProductSku NotSet = new(string.Empty); + + /// + /// Converts a string to a product identifier. + /// + /// The value to convert. + /// The converted product identifier. + public static implicit operator ProductSku(string value) => new(value); +} + +/// +/// Represents the number of units requested by an order. +/// +/// The underlying quantity. +public record OrderQuantity(int Value) : ConceptAs(Value) +{ + /// + /// Represents an unset order quantity. + /// + public static readonly OrderQuantity NotSet = new(0); + + /// + /// Converts an integer to an order quantity. + /// + /// The value to convert. + /// The converted order quantity. + public static implicit operator OrderQuantity(int value) => new(value); +} + +/// +/// Represents a target-owned product identifier used during fulfillment. +/// +/// The underlying stock-keeping unit. +public record FulfillmentSku(string Value) : ConceptAs(Value) +{ + /// + /// Represents an unset fulfillment product identifier. + /// + public static readonly FulfillmentSku NotSet = new(string.Empty); + + /// + /// Converts a string to a fulfillment product identifier. + /// + /// The value to convert. + /// The converted fulfillment product identifier. + public static implicit operator FulfillmentSku(string value) => new(value); +} + +/// +/// Represents the target-owned number of units to fulfill. +/// +/// The underlying quantity. +public record UnitsToFulfill(int Value) : ConceptAs(Value) +{ + /// + /// Represents an unset fulfillment quantity. + /// + public static readonly UnitsToFulfill NotSet = new(0); + + /// + /// Converts an integer to a fulfillment quantity. + /// + /// The value to convert. + /// The converted fulfillment quantity. + public static implicit operator UnitsToFulfill(int value) => new(value); +} diff --git a/Chronicle/CrossStore/Events.cs b/Chronicle/CrossStore/Events.cs new file mode 100644 index 00000000..7d9e778f --- /dev/null +++ b/Chronicle/CrossStore/Events.cs @@ -0,0 +1,31 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Chronicle.Events; + +namespace CrossStore; + +/// +/// Records that the orders domain accepted an order. +/// +/// The source-owned buyer reference. +/// The ordered product. +/// The ordered quantity. +[EventType] +public record OrderPlaced(BuyerReference Buyer, ProductSku Sku, OrderQuantity Quantity); + +/// +/// Publishes only the information fulfillment needs from an accepted order. +/// +/// The product to fulfill. +/// The quantity to fulfill. +[EventType] +public record OrderRequestedForFulfillment(ProductSku Sku, OrderQuantity Quantity); + +/// +/// Records the fulfillment domain's local interpretation of an incoming order contract. +/// +/// The target-owned product identifier. +/// The target-owned quantity to fulfill. +[EventType] +public record FulfillmentOrderReceived(FulfillmentSku Sku, UnitsToFulfill Quantity); diff --git a/Chronicle/CrossStore/FulfillmentOrder.cs b/Chronicle/CrossStore/FulfillmentOrder.cs new file mode 100644 index 00000000..be5da325 --- /dev/null +++ b/Chronicle/CrossStore/FulfillmentOrder.cs @@ -0,0 +1,15 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Chronicle.Projections.ModelBound; + +namespace CrossStore; + +/// +/// Provides the fulfillment-owned view of an order received from another event store. +/// +/// The fulfillment order identity. +/// The target-owned product identifier. +/// The target-owned quantity to fulfill. +[FromEvent] +public record FulfillmentOrder(FulfillmentOrderId Id, FulfillmentSku Sku, UnitsToFulfill Quantity); diff --git a/Chronicle/CrossStore/FulfillmentTranslationReactor.cs b/Chronicle/CrossStore/FulfillmentTranslationReactor.cs new file mode 100644 index 00000000..6fbddc7f --- /dev/null +++ b/Chronicle/CrossStore/FulfillmentTranslationReactor.cs @@ -0,0 +1,27 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Chronicle.EventSequences; +using Cratis.Chronicle.Reactors; + +namespace CrossStore; + +/// +/// Translates the source-owned contract into a fulfillment-owned fact. +/// +/// +/// The reactor observes only the orders inbox. Its returned event is appended to the target event log +/// with the incoming event source identity, where target-owned projections can consume it. +/// +[Reactor] +[EventSequence(EventSequenceId.InboxPrefix + StoreNames.Orders)] +public class FulfillmentTranslationReactor : IReactor +{ + /// + /// Translates an incoming order request without leaking the source domain model into fulfillment. + /// + /// The source-owned contract fact. + /// The target-owned local fact. + public FulfillmentOrderReceived Requested(OrderRequestedForFulfillment @event) => + new(@event.Sku.Value, @event.Quantity.Value); +} diff --git a/Chronicle/CrossStore/OrderId.cs b/Chronicle/CrossStore/OrderId.cs new file mode 100644 index 00000000..c409c74a --- /dev/null +++ b/Chronicle/CrossStore/OrderId.cs @@ -0,0 +1,50 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Chronicle.Events; + +namespace CrossStore; + +/// +/// Represents an order identity in the source store. +/// +/// The underlying identifier. +public record OrderId(Guid Value) : EventSourceId(Value) +{ + /// + /// Represents an unset order identity. + /// + public static readonly OrderId NotSet = new(Guid.Empty); + + /// + /// Converts a to an order identity. + /// + /// The value to convert. + /// The converted order identity. + public static implicit operator OrderId(Guid value) => new(value); + + /// + /// Creates a new order identity. + /// + /// A new order identity. + public static OrderId New() => new(Guid.NewGuid()); +} + +/// +/// Represents the target store's identity for a fulfillment order. +/// +/// The underlying identifier. +public record FulfillmentOrderId(Guid Value) : EventSourceId(Value) +{ + /// + /// Represents an unset fulfillment order identity. + /// + public static readonly FulfillmentOrderId NotSet = new(Guid.Empty); + + /// + /// Converts a to a fulfillment order identity. + /// + /// The value to convert. + /// The converted fulfillment order identity. + public static implicit operator FulfillmentOrderId(Guid value) => new(value); +} diff --git a/Chronicle/CrossStore/Program.cs b/Chronicle/CrossStore/Program.cs new file mode 100644 index 00000000..25b83901 --- /dev/null +++ b/Chronicle/CrossStore/Program.cs @@ -0,0 +1,77 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Chronicle; +using Cratis.Chronicle.Events; +using Cratis.Chronicle.EventSequences; +using CrossStore; + +var builder = WebApplication.CreateBuilder(args) + .AddCratisChronicle(options => options.EventStore = StoreNames.Fulfillment); + +var app = builder.Build(); +app.UseCratisChronicle(); + +var chronicle = app.Services.GetRequiredService(); +await chronicle.GetEventStore(StoreNames.Orders); +var fulfillmentStore = await chronicle.GetEventStore(StoreNames.Fulfillment); +await fulfillmentStore.Subscriptions.Subscribe( + StoreSubscriptionIds.OrdersForFulfillment, + StoreNames.Orders, + subscription => subscription.WithEventType()); + +app.MapGet("/", () => Results.Ok(new +{ + Sample = "Chronicle cross-store outbox/inbox", + SourceEventStore = StoreNames.Orders, + TargetEventStore = StoreNames.Fulfillment, + Subscription = StoreSubscriptionIds.OrdersForFulfillment, + PlaceOrder = "POST /orders/{orderId}", + FulfillmentOrder = "GET /fulfillment/orders/{orderId}" +})); + +app.MapPost("/orders/{orderId:guid}", async (Guid orderId, PlaceOrderRequest request, IChronicleClient client) => +{ + if (string.IsNullOrWhiteSpace(request.Buyer) || string.IsNullOrWhiteSpace(request.Sku) || request.Quantity <= 0) + { + return Results.BadRequest(new { Error = "Buyer and sku are required, and quantity must be greater than zero." }); + } + + OrderId typedOrderId = orderId; + BuyerReference buyer = request.Buyer.Trim(); + ProductSku sku = request.Sku.Trim(); + OrderQuantity quantity = request.Quantity; + var ordersStore = await client.GetEventStore(StoreNames.Orders); + + var orderAppend = await ordersStore.EventLog.Append(typedOrderId, new OrderPlaced(buyer, sku, quantity)); + if (!orderAppend.IsSuccess) + { + return Results.Problem("Chronicle rejected the source-owned order fact."); + } + + var contractAppend = await ordersStore + .GetEventSequence(EventSequenceId.Outbox) + .Append(typedOrderId, new OrderRequestedForFulfillment(sku, quantity)); + + if (!contractAppend.IsSuccess) + { + return Results.Problem( + "The order was recorded, but publishing its fulfillment contract failed. The source fact was not rolled back.", + statusCode: StatusCodes.Status503ServiceUnavailable); + } + + return Results.Accepted( + $"/fulfillment/orders/{orderId}", + new PlaceOrderAccepted(orderId, StoreNames.Orders, StoreNames.Fulfillment)); +}); + +app.MapGet("/fulfillment/orders/{orderId:guid}", async (Guid orderId, IChronicleClient client) => +{ + var targetStore = await client.GetEventStore(StoreNames.Fulfillment); + FulfillmentOrderId fulfillmentOrderId = orderId; + var order = await targetStore.ReadModels.GetInstanceById((EventSourceId)fulfillmentOrderId); + + return order is null ? Results.NotFound() : Results.Ok(order); +}); + +await app.RunAsync(); diff --git a/Chronicle/CrossStore/README.md b/Chronicle/CrossStore/README.md new file mode 100644 index 00000000..9edb61a8 --- /dev/null +++ b/Chronicle/CrossStore/README.md @@ -0,0 +1,229 @@ +
+ +# Chronicle cross-store flow + +### Two bounded contexts. Two event stores. One Chronicle server + +**Typed identities · Narrow contracts · Filtered outbox/inbox · Local translation** + +
+ +--- + +This backend-only sample shows how one process can keep **Orders** and **Fulfillment** in separate named Chronicle event stores while connecting them with an explicit, filtered subscription. + +Orders publishes a deliberately narrow contract to its outbox. Chronicle forwards that contract to Fulfillment's source-specific inbox. A Fulfillment reactor translates it into a target-owned event, and a target-owned projection materializes the local read model. + +## Architecture + +```mermaid +flowchart LR + Client[HTTP client] -->|POST order| API[ASP.NET Core minimal API] + + subgraph Server[One Chronicle server] + subgraph Orders[CrossStoreOrders event store] + OrdersLog[(event log)] + Outbox[(outbox)] + end + + subgraph Fulfillment[CrossStoreFulfillment event store] + Inbox[(inbox-CrossStoreOrders)] + Reactor[Fulfillment translation reactor] + FulfillmentLog[(event log)] + Projection[model-bound projection] + View[(FulfillmentOrder)] + end + end + + API -->|OrderPlaced| OrdersLog + API -->|OrderRequestedForFulfillment| Outbox + Outbox -->|explicit subscription
filtered by contract type| Inbox + Inbox --> Reactor + Reactor -->|returns FulfillmentOrderReceived| FulfillmentLog + FulfillmentLog --> Projection + Projection --> View + Client -->|GET fulfillment order| API + API --> View +``` + +| Boundary | Owner | Stored fact | +| --- | --- | --- | +| Orders event log | Orders | `OrderPlaced` includes the source-owned buyer reference. | +| Orders outbox | Orders contract | `OrderRequestedForFulfillment` includes only SKU and quantity. | +| Fulfillment inbox | Chronicle subscription | The forwarded contract arrives on `inbox-CrossStoreOrders`. | +| Fulfillment event log | Fulfillment | `FulfillmentOrderReceived` uses target-owned value types. | +| Fulfillment read model | Fulfillment | `FulfillmentOrder` is projected only from the local event. | + +The event source identity is not duplicated inside any event payload. Chronicle carries it in event context; Orders models it as `OrderId : EventSourceId`, while Fulfillment owns `FulfillmentOrderId : EventSourceId`. + +## Delivery semantics: intentionally honest + +This sample demonstrates asynchronous store integration, not a distributed transaction. + +- Recording `OrderPlaced` and appending `OrderRequestedForFulfillment` are **two separate appends**. If outbox publication fails, the order fact remains recorded and the API reports that no rollback occurred. +- Outbox forwarding, inbox observation, local event append, and read-model projection complete asynchronously. +- The explicit subscription is registered idempotently at startup and is persisted by Chronicle, but consumers must still be designed for retries and possible duplicate delivery. +- Nothing here claims exactly-once delivery. Production code should add an explicit publication-recovery strategy and consumer idempotency appropriate to its domain. + +Those boundaries are visible on purpose: a sample should not hide failure modes behind a synchronous-looking abstraction. + +## Prerequisites + +- The .NET SDK selected by the repository's `global.json` (the sample targets .NET 10). +- Docker Desktop or another Docker-compatible runtime. +- Ports `35000` and `5097` available locally. +- A shell with `curl`. + +The projects use only versions from the repository's central package management. + +## Run it + +Run all commands from the Samples repository root. + +### 1. Start one Chronicle development server + +```bash +docker run --rm --name chronicle-cross-store-sample \ + -p 35000:35000 \ + cratis/chronicle:latest-development +``` + +That single server hosts both `CrossStoreOrders` and `CrossStoreFulfillment`. + +### 2. Start the API + +```bash +dotnet restore Chronicle/CrossStore/CrossStore.csproj +dotnet run \ + --project Chronicle/CrossStore/CrossStore.csproj \ + --no-restore \ + --urls http://localhost:5097 +``` + +At startup, the application: + +1. obtains both named stores from the same `IChronicleClient`; +2. registers `orders-for-fulfillment` on the target store; and +3. filters the subscription with `WithEventType()`. + +Inspect the configured flow: + +```bash +curl --silent http://localhost:5097/ | jq +``` + +### 3. Place an order + +Use a stable id so the read request addresses the corresponding target event source: + +```bash +ORDER_ID=50d4f22e-c61e-40fb-8728-a88c2fc9326d + +curl --include \ + --request POST \ + --header 'Content-Type: application/json' \ + --data '{"buyer":"buyer-1042","sku":"SKU-42","quantity":3}' \ + "http://localhost:5097/orders/${ORDER_ID}" +``` + +Expected shape: + +```http +HTTP/1.1 202 Accepted +Location: /fulfillment/orders/50d4f22e-c61e-40fb-8728-a88c2fc9326d + +{"orderId":"50d4f22e-c61e-40fb-8728-a88c2fc9326d","sourceEventStore":"CrossStoreOrders","targetEventStore":"CrossStoreFulfillment"} +``` + +`202 Accepted` is deliberate: the cross-store work continues asynchronously after the source appends complete. + +### 4. Read Fulfillment's local view + +```bash +curl --silent \ + "http://localhost:5097/fulfillment/orders/${ORDER_ID}" | jq +``` + +If materialization is still catching up, the endpoint returns `404`; retry the GET. Once complete, the response is shaped like: + +```json +{ + "id": "50d4f22e-c61e-40fb-8728-a88c2fc9326d", + "sku": "SKU-42", + "quantity": 3 +} +``` + +The GET does not query Orders and does not read the inbox contract directly. It returns a Fulfillment-owned projection of a Fulfillment-owned local event. + +## Code tour + +| File | Why it exists | +| --- | --- | +| `Program.cs` | Composes both stores, registers the filtered target subscription, and exposes the two HTTP operations. | +| `ApiModels.cs` | Keeps transport request and acceptance models in the sample namespace. | +| `StoreNames.cs` | Keeps store and subscription identities stable and obvious. | +| `OrderId.cs` | Defines distinct source and target `EventSourceId` identities. | +| `DomainValues.cs` | Defines source, contract, and target `ConceptAs` values rather than passing primitives through the model. | +| `Events.cs` | Places the private source fact, narrow contract fact, and target-owned local fact side by side for comparison. | +| `FulfillmentTranslationReactor.cs` | Observes only `inbox-CrossStoreOrders` and returns the local event to the target event log. | +| `FulfillmentOrder.cs` | Projects the local event into the target read model. | +| `CrossStore.Specs/` | Protects the contract boundary and the source-to-target translation with five focused assertions. | + +## The important lines + +The target creates an explicit subscription and forwards only one contract type: + +```csharp +await fulfillmentStore.Subscriptions.Subscribe( + StoreSubscriptionIds.OrdersForFulfillment, + StoreNames.Orders, + subscription => subscription.WithEventType()); +``` + +The producer publishes the contract to its outbox, not to the target store: + +```csharp +await ordersStore + .GetEventSequence(EventSequenceId.Outbox) + .Append(orderId, new OrderRequestedForFulfillment(sku, quantity)); +``` + +The target reactor is pinned to the source-specific inbox and returns a target-owned fact. Chronicle appends that returned fact to Fulfillment's event log: + +```csharp +[EventSequence(EventSequenceId.InboxPrefix + StoreNames.Orders)] +public class FulfillmentTranslationReactor : IReactor +{ + public FulfillmentOrderReceived Requested(OrderRequestedForFulfillment @event) => + new(@event.Sku.Value, @event.Quantity.Value); +} +``` + +## Build and specs + +The sample is intentionally not added to the root solution or manifests. Target it directly: + +```bash +dotnet build Chronicle/CrossStore/CrossStore.csproj +dotnet test Chronicle/CrossStore/CrossStore.Specs/CrossStore.Specs.csproj +``` + +The specs stay small and useful: + +- the public contract exposes only SKU and quantity, never the source-owned buyer reference; +- the reactor produces the target-owned event type; and +- translation crosses into target-owned `ConceptAs` values. + +## Ideas to try + +1. **Add another outbox fact** and prove the existing filter does not forward it. +2. **Introduce publication recovery** for the gap between the source event-log append and outbox append. +3. **Make the target idempotent** by recording a processed-message identity before a non-repeatable side effect. +4. **Add a second consumer store** with a different contract filter and its own local translation. +5. **Observe failures** by making the reactor reject one contract, then inspect and recover the failed partition. +6. **Add correlation metadata** and trace the source append, forwarding, translation, and projection without coupling the stores. + +## Intentional limitations + +This is a focused learning sample, not a production template. It omits authentication, authorization, concurrency policies, schema migration, tenant routing, publication repair, operational dashboards, integration-container specs, and deployment configuration. Add those concerns deliberately without weakening the event-store ownership boundaries shown here. diff --git a/Chronicle/CrossStore/StoreNames.cs b/Chronicle/CrossStore/StoreNames.cs new file mode 100644 index 00000000..b1e69705 --- /dev/null +++ b/Chronicle/CrossStore/StoreNames.cs @@ -0,0 +1,31 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace CrossStore; + +/// +/// Names the two event stores hosted by the same Chronicle server. +/// +public static class StoreNames +{ + /// + /// The source event store that owns order placement. + /// + public const string Orders = "CrossStoreOrders"; + + /// + /// The target event store that owns fulfillment work. + /// + public const string Fulfillment = "CrossStoreFulfillment"; +} + +/// +/// Names the target-owned subscription from the orders outbox. +/// +public static class StoreSubscriptionIds +{ + /// + /// The stable identifier for the filtered orders-to-fulfillment subscription. + /// + public const string OrdersForFulfillment = "orders-for-fulfillment"; +} From f689bc19da9694795e9f55eceb3ea4361b4aac1f Mon Sep 17 00:00:00 2001 From: woksin Date: Sun, 23 Aug 2026 01:30:27 +0200 Subject: [PATCH 04/12] Add Chronicle multi-tenancy sample --- Chronicle/MultiTenancy/Items/Adding/Adding.cs | 29 ++++ Chronicle/MultiTenancy/Items/ItemId.cs | 30 ++++ Chronicle/MultiTenancy/Items/ItemText.cs | 24 +++ .../MultiTenancy/Items/Listing/Listing.cs | 29 ++++ .../MultiTenancy.Specs/GlobalUsings.cs | 13 ++ .../MultiTenancy.Specs.csproj | 22 +++ .../for_AddItem/when_adding_an_item.cs | 14 ++ .../for_Item/when_an_item_is_added.cs | 17 +++ .../when_using_the_same_item_id.cs | 52 +++++++ Chronicle/MultiTenancy/MultiTenancy.csproj | 18 +++ Chronicle/MultiTenancy/Program.cs | 39 +++++ .../Properties/launchSettings.json | 14 ++ Chronicle/MultiTenancy/README.md | 140 ++++++++++++++++++ 13 files changed, 441 insertions(+) create mode 100644 Chronicle/MultiTenancy/Items/Adding/Adding.cs create mode 100644 Chronicle/MultiTenancy/Items/ItemId.cs create mode 100644 Chronicle/MultiTenancy/Items/ItemText.cs create mode 100644 Chronicle/MultiTenancy/Items/Listing/Listing.cs create mode 100644 Chronicle/MultiTenancy/MultiTenancy.Specs/GlobalUsings.cs create mode 100644 Chronicle/MultiTenancy/MultiTenancy.Specs/MultiTenancy.Specs.csproj create mode 100644 Chronicle/MultiTenancy/MultiTenancy.Specs/for_AddItem/when_adding_an_item.cs create mode 100644 Chronicle/MultiTenancy/MultiTenancy.Specs/for_Item/when_an_item_is_added.cs create mode 100644 Chronicle/MultiTenancy/MultiTenancy.Specs/for_namespace_isolation/when_using_the_same_item_id.cs create mode 100644 Chronicle/MultiTenancy/MultiTenancy.csproj create mode 100644 Chronicle/MultiTenancy/Program.cs create mode 100644 Chronicle/MultiTenancy/Properties/launchSettings.json create mode 100644 Chronicle/MultiTenancy/README.md diff --git a/Chronicle/MultiTenancy/Items/Adding/Adding.cs b/Chronicle/MultiTenancy/Items/Adding/Adding.cs new file mode 100644 index 00000000..834e3a4d --- /dev/null +++ b/Chronicle/MultiTenancy/Items/Adding/Adding.cs @@ -0,0 +1,29 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Commands.ModelBound; +using Cratis.Chronicle.Events; + +namespace MultiTenancy.Items.Adding; + +/// +/// Adds an item to the current tenant's checklist. +/// +/// The checklist item identifier. +/// The item text. +[Command] +public record AddItem(ItemId ItemId, ItemText Text) +{ + /// + /// Produces the fact that the item was added. + /// + /// The event to append to the tenant-scoped event source. + public ItemAdded Handle() => new(Text); +} + +/// +/// Records that an item was added to a tenant's checklist. +/// +/// The item text. +[EventType] +public record ItemAdded(ItemText Text); diff --git a/Chronicle/MultiTenancy/Items/ItemId.cs b/Chronicle/MultiTenancy/Items/ItemId.cs new file mode 100644 index 00000000..0f8838f9 --- /dev/null +++ b/Chronicle/MultiTenancy/Items/ItemId.cs @@ -0,0 +1,30 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Chronicle; + +namespace MultiTenancy.Items; + +/// +/// Identifies one checklist item event source. +/// +/// The underlying identifier. +public record ItemId(Guid Value) : EventSourceId(Value) +{ + /// + /// Represents an identifier that has not been set. + /// + public static readonly ItemId NotSet = new(Guid.Empty); + + /// + /// Converts a to an . + /// + /// The value to convert. + public static implicit operator ItemId(Guid value) => new(value); + + /// + /// Creates a new checklist item identifier. + /// + /// A new checklist item identifier. + public static ItemId New() => new(Guid.NewGuid()); +} diff --git a/Chronicle/MultiTenancy/Items/ItemText.cs b/Chronicle/MultiTenancy/Items/ItemText.cs new file mode 100644 index 00000000..73aadc5f --- /dev/null +++ b/Chronicle/MultiTenancy/Items/ItemText.cs @@ -0,0 +1,24 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Concepts; + +namespace MultiTenancy.Items; + +/// +/// Represents the text of a checklist item. +/// +/// The text value. +public record ItemText(string Value) : ConceptAs(Value) +{ + /// + /// Represents text that has not been set. + /// + public static readonly ItemText NotSet = new(string.Empty); + + /// + /// Converts a string to checklist item text. + /// + /// The value to convert. + public static implicit operator ItemText(string value) => new(value); +} diff --git a/Chronicle/MultiTenancy/Items/Listing/Listing.cs b/Chronicle/MultiTenancy/Items/Listing/Listing.cs new file mode 100644 index 00000000..bb9a0e82 --- /dev/null +++ b/Chronicle/MultiTenancy/Items/Listing/Listing.cs @@ -0,0 +1,29 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Queries.ModelBound; +using Cratis.Chronicle; +using Cratis.Chronicle.Projections.ModelBound; +using Cratis.Chronicle.ReadModels; +using MultiTenancy.Items.Adding; + +namespace MultiTenancy.Items.Listing; + +/// +/// Represents one tenant-scoped checklist item. +/// +/// The checklist item identifier. +/// The item text. +[ReadModel] +[FromEvent] +public record Item(ItemId Id, ItemText Text) +{ + /// + /// Gets one checklist item from the current tenant's Chronicle namespace. + /// + /// The Chronicle read models. + /// The checklist item identifier. + /// The item when it exists in the current tenant, otherwise . + public static async Task ItemById(IReadModels readModels, ItemId id) => + await readModels.GetInstanceById((EventSourceId)id); +} diff --git a/Chronicle/MultiTenancy/MultiTenancy.Specs/GlobalUsings.cs b/Chronicle/MultiTenancy/MultiTenancy.Specs/GlobalUsings.cs new file mode 100644 index 00000000..719a24cc --- /dev/null +++ b/Chronicle/MultiTenancy/MultiTenancy.Specs/GlobalUsings.cs @@ -0,0 +1,13 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +global using Cratis.Chronicle; +global using Cratis.Chronicle.Events; +global using Cratis.Chronicle.EventSequences; +global using Cratis.Chronicle.Testing.EventSequences; +global using Cratis.Chronicle.Testing.ReadModels; +global using Cratis.Specifications; +global using MultiTenancy.Items; +global using MultiTenancy.Items.Adding; +global using MultiTenancy.Items.Listing; +global using Xunit; diff --git a/Chronicle/MultiTenancy/MultiTenancy.Specs/MultiTenancy.Specs.csproj b/Chronicle/MultiTenancy/MultiTenancy.Specs/MultiTenancy.Specs.csproj new file mode 100644 index 00000000..24ec9205 --- /dev/null +++ b/Chronicle/MultiTenancy/MultiTenancy.Specs/MultiTenancy.Specs.csproj @@ -0,0 +1,22 @@ + + + MultiTenancy.Specs + true + false + + + + + + + + + + + + + + all + + + diff --git a/Chronicle/MultiTenancy/MultiTenancy.Specs/for_AddItem/when_adding_an_item.cs b/Chronicle/MultiTenancy/MultiTenancy.Specs/for_AddItem/when_adding_an_item.cs new file mode 100644 index 00000000..d072a5f9 --- /dev/null +++ b/Chronicle/MultiTenancy/MultiTenancy.Specs/for_AddItem/when_adding_an_item.cs @@ -0,0 +1,14 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace MultiTenancy.Specs.for_AddItem; + +public class when_adding_an_item : Specification +{ + readonly ItemText _text = "Review the tenant boundary"; + ItemAdded _result = null!; + + void Because() => _result = new AddItem(ItemId.New(), _text).Handle(); + + [Fact] void should_record_the_item_text() => _result.Text.ShouldEqual(_text); +} diff --git a/Chronicle/MultiTenancy/MultiTenancy.Specs/for_Item/when_an_item_is_added.cs b/Chronicle/MultiTenancy/MultiTenancy.Specs/for_Item/when_an_item_is_added.cs new file mode 100644 index 00000000..df2ebd4f --- /dev/null +++ b/Chronicle/MultiTenancy/MultiTenancy.Specs/for_Item/when_an_item_is_added.cs @@ -0,0 +1,17 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace MultiTenancy.Specs.for_Item; + +public class when_an_item_is_added : Specification +{ + readonly ItemId _itemId = ItemId.New(); + readonly ItemText _text = "Project into this tenant"; + readonly ReadModelScenario _scenario = new(); + + async Task Because() => + await _scenario.Given.ForEventSource(_itemId).Events(new ItemAdded(_text)); + + [Fact] void should_use_the_event_source_id() => _scenario.Instance.Id.ShouldEqual(_itemId); + [Fact] void should_project_the_item_text() => _scenario.Instance.Text.ShouldEqual(_text); +} diff --git a/Chronicle/MultiTenancy/MultiTenancy.Specs/for_namespace_isolation/when_using_the_same_item_id.cs b/Chronicle/MultiTenancy/MultiTenancy.Specs/for_namespace_isolation/when_using_the_same_item_id.cs new file mode 100644 index 00000000..645eca17 --- /dev/null +++ b/Chronicle/MultiTenancy/MultiTenancy.Specs/for_namespace_isolation/when_using_the_same_item_id.cs @@ -0,0 +1,52 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace MultiTenancy.Specs.for_namespace_isolation; + +public class when_using_the_same_item_id : Specification +{ + readonly ItemId _itemId = ItemId.New(); + readonly EventScenario _tenantA = new(EventSequenceId.Log, "MultiTenancy", "tenant-a", null); + readonly EventScenario _tenantB = new(EventSequenceId.Log, "MultiTenancy", "tenant-b", null); + readonly EventScenario _default = new(EventSequenceId.Log, "MultiTenancy", EventStoreNamespaceName.Default, null); + ItemAdded _tenantAItem = null!; + ItemAdded _tenantBItem = null!; + ItemAdded _defaultItem = null!; + EventStoreNamespaceName _tenantANamespace = EventStoreNamespaceName.NotSet; + EventStoreNamespaceName _tenantBNamespace = EventStoreNamespaceName.NotSet; + EventStoreNamespaceName _defaultNamespace = EventStoreNamespaceName.NotSet; + + async Task Because() + { + var tenantAResult = await _tenantA.EventLog.Append(_itemId, new ItemAdded("Visible only in tenant A")); + var tenantBResult = await _tenantB.EventLog.Append(_itemId, new ItemAdded("Visible only in tenant B")); + var defaultResult = await _default.EventLog.Append(_itemId, new ItemAdded("Visible only in Default")); + + _tenantANamespace = tenantAResult.EventStoreNamespace; + _tenantBNamespace = tenantBResult.EventStoreNamespace; + _defaultNamespace = defaultResult.EventStoreNamespace; + _tenantAItem = await ReadItem(_tenantA); + _tenantBItem = await ReadItem(_tenantB); + _defaultItem = await ReadItem(_default); + } + + void Destroy() + { + _tenantA.Dispose(); + _tenantB.Dispose(); + _default.Dispose(); + } + + [Fact] void should_append_tenant_a_to_its_namespace() => _tenantANamespace.ShouldEqual((EventStoreNamespaceName)"tenant-a"); + [Fact] void should_append_tenant_b_to_its_namespace() => _tenantBNamespace.ShouldEqual((EventStoreNamespaceName)"tenant-b"); + [Fact] void should_append_default_to_its_namespace() => _defaultNamespace.ShouldEqual(EventStoreNamespaceName.Default); + [Fact] void should_keep_tenant_a_value() => _tenantAItem.Text.ShouldEqual((ItemText)"Visible only in tenant A"); + [Fact] void should_keep_tenant_b_value() => _tenantBItem.Text.ShouldEqual((ItemText)"Visible only in tenant B"); + [Fact] void should_keep_default_value() => _defaultItem.Text.ShouldEqual((ItemText)"Visible only in Default"); + + async Task ReadItem(EventScenario scenario) + { + var events = await scenario.EventLog.GetFromSequenceNumber(EventSequenceNumber.First, _itemId); + return events.Select(_ => _.Content).OfType().Single(); + } +} diff --git a/Chronicle/MultiTenancy/MultiTenancy.csproj b/Chronicle/MultiTenancy/MultiTenancy.csproj new file mode 100644 index 00000000..1e682ae4 --- /dev/null +++ b/Chronicle/MultiTenancy/MultiTenancy.csproj @@ -0,0 +1,18 @@ + + + MultiTenancy + + + + + + + + + + + + + + + diff --git a/Chronicle/MultiTenancy/Program.cs b/Chronicle/MultiTenancy/Program.cs new file mode 100644 index 00000000..6c83d9a0 --- /dev/null +++ b/Chronicle/MultiTenancy/Program.cs @@ -0,0 +1,39 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Tenancy; +using AspNetCoreArcBuilderExtensions = Microsoft.AspNetCore.Builder.ArcBuilderExtensions; + +var builder = WebApplication.CreateBuilder(args) + .AddCratisArc( + options => + { + options.UseHeaderTenancy(); + options.GeneratedApis.RoutePrefix = "api"; + options.GeneratedApis.SegmentsToSkipForRoute = 1; + }, + arcBuilder => AspNetCoreArcBuilderExtensions.WithChronicle( + arcBuilder, + options => options.EventStore = "MultiTenancy")); + +builder.UseCratisMongoDB(options => +{ + options.Server = "mongodb://localhost:27017"; + options.Database = "MultiTenancy"; +}); + +var app = builder.Build(); + +app.UseRouting(); +app.UseWebSockets(); +app.UseCratisArc(); +app.UseCratisChronicle(); + +app.MapGet("/", () => Results.Ok(new +{ + Sample = "Arc and Chronicle multi-tenancy", + TenantHeader = "x-cratis-tenant-id", + DefaultNamespace = "Default" +})); + +await app.RunAsync(); diff --git a/Chronicle/MultiTenancy/Properties/launchSettings.json b/Chronicle/MultiTenancy/Properties/launchSettings.json new file mode 100644 index 00000000..2cb4fab9 --- /dev/null +++ b/Chronicle/MultiTenancy/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "MultiTenancy": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5097", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/Chronicle/MultiTenancy/README.md b/Chronicle/MultiTenancy/README.md new file mode 100644 index 00000000..9d0bc395 --- /dev/null +++ b/Chronicle/MultiTenancy/README.md @@ -0,0 +1,140 @@ +
+ +# Tenant-scoped checklists + +### One Arc API · one Chronicle store · one namespace per tenant + +**Header tenancy · Model-bound CQRS · Typed event sources · MongoDB read models** + +
+ +--- + +This backend-only sample shows the smallest useful Arc + Chronicle multi-tenant workflow: add a checklist item, then read its projected view. The same `ItemId` can exist in tenant A, tenant B, and `Default` without sharing events or read models. + +## Architecture + +```mermaid +flowchart LR + A[Tenant A client] -->|x-cratis-tenant-id: tenant-a| Arc[Arc generated API] + B[Tenant B client] -->|x-cratis-tenant-id: tenant-b| Arc + D[No tenant header] -->|Default| Arc + + Arc --> Resolver[Arc tenant resolver] + Resolver --> Namespace[TenantNamespaceResolver] + + Namespace --> NA[(Chronicle / tenant-a)] + Namespace --> NB[(Chronicle / tenant-b)] + Namespace --> ND[(Chronicle / Default)] + + NA --> RMA[(Item read model)] + NB --> RMB[(Item read model)] + ND --> RMD[(Item read model)] +``` + +`options.UseHeaderTenancy()` reads Arc's standard `x-cratis-tenant-id` header. Arc's Chronicle integration installs `TenantNamespaceResolver`, which maps the current tenant to a Chronicle namespace; an absent header and the explicit tenant name `Default` both select Chronicle's default namespace. The tenant id is therefore not duplicated on events. + +## Run it + +Prerequisites: the repository's .NET 10 SDK, Docker, and `curl`. + +From the repository root, start the Chronicle development container. It exposes Chronicle on `35000` and its embedded MongoDB on `27017`: + +```bash +docker run --rm --name chronicle-multitenancy-sample \ + -p 27017:27017 \ + -p 35000:35000 \ + cratis/chronicle:latest-development +``` + +In another terminal: + +```bash +dotnet run --project Chronicle/MultiTenancy/MultiTenancy.csproj +``` + +The sample listens on `http://localhost:5097` through its launch profile. + +## Try two tenants + +Use one stable event-source identifier in every request: + +```bash +ITEM_ID=6d88cc61-4b5a-4c29-a9cd-12d659b1671e +``` + +Add the item for tenant A: + +```bash +curl --request POST http://localhost:5097/api/items/adding/add-item \ + --header 'Content-Type: application/json' \ + --header 'x-cratis-tenant-id: tenant-a' \ + --data "{\"itemId\":\"${ITEM_ID}\",\"text\":\"Prepare tenant A release\"}" +``` + +Add a different fact at the **same `ItemId`** for tenant B: + +```bash +curl --request POST http://localhost:5097/api/items/adding/add-item \ + --header 'Content-Type: application/json' \ + --header 'x-cratis-tenant-id: tenant-b' \ + --data "{\"itemId\":\"${ITEM_ID}\",\"text\":\"Review tenant B metrics\"}" +``` + +Read each tenant's projected item through the model-bound query: + +```bash +curl --get http://localhost:5097/api/items/listing/item-by-id \ + --header 'x-cratis-tenant-id: tenant-a' \ + --data-urlencode "id=${ITEM_ID}" + +curl --get http://localhost:5097/api/items/listing/item-by-id \ + --header 'x-cratis-tenant-id: tenant-b' \ + --data-urlencode "id=${ITEM_ID}" +``` + +The first response contains `Prepare tenant A release`; the second contains `Review tenant B metrics`. Chronicle projections are asynchronous, so repeat a read if it races the first projection update. + +To use the third isolation boundary, omit the header. This writes and reads the same identifier in `Default`: + +```bash +curl --request POST http://localhost:5097/api/items/adding/add-item \ + --header 'Content-Type: application/json' \ + --data "{\"itemId\":\"${ITEM_ID}\",\"text\":\"Check the default workspace\"}" + +curl --get http://localhost:5097/api/items/listing/item-by-id \ + --data-urlencode "id=${ITEM_ID}" +``` + +## Code tour + +| File | Purpose | +| --- | --- | +| `Program.cs` | Selects Arc header tenancy, wires Arc to Chronicle, and configures tenant-aware MongoDB read models. | +| `Items/ItemId.cs` | Defines the stream identity as `EventSourceId`. | +| `Items/ItemText.cs` | Defines the domain value as `ConceptAs`. | +| `Items/Adding/Adding.cs` | Contains the model-bound `AddItem` command and immutable `ItemAdded` event. | +| `Items/Listing/Listing.cs` | Projects `ItemAdded` into the model-bound `Item` read model and exposes `ItemById`. | +| `MultiTenancy.Specs/` | Covers event construction, projection, and the same id in tenant A, tenant B, and `Default`. | + +## Build and verify + +The sample intentionally remains outside the shared solution; target its projects directly: + +```bash +dotnet build Chronicle/MultiTenancy/MultiTenancy.csproj +dotnet test Chronicle/MultiTenancy/MultiTenancy.Specs/MultiTenancy.Specs.csproj +``` + +The focused namespace-isolation spec uses Chronicle's in-process event scenario, so the test suite needs no container. + +## Ideas to try + +- Send `x-cratis-tenant-id: Default` and confirm it addresses the same namespace as no header. +- Add an item that exists in only one tenant, then query that id from another tenant. +- Inspect MongoDB and compare the default read-model database with tenant-suffixed databases. +- Add a second event, such as `ItemCompleted`, and watch each tenant's projection evolve independently. + +## Intentional limits + +This is a learning sample, not a production tenancy policy. It has no authentication, tenant allow-list, authorization, idempotency, or production connection configuration. A caller that can choose any header can choose any namespace; real systems must derive or validate tenant access at a trusted boundary. There is no React client, cross-store messaging, or tenant id property on the event. From db49829be9697b69d8f983e0c7fc1b3d854cb163 Mon Sep 17 00:00:00 2001 From: woksin Date: Sun, 23 Aug 2026 01:42:21 +0200 Subject: [PATCH 05/12] Add standalone Arc and React idea board sample Demonstrate Arc CQRS without Chronicle through a strongly typed current-state slice, generated TypeScript contracts, a small React 19 UI, and focused specifications. --- Arc/React/.frontend/App.tsx | 18 + Arc/React/.frontend/index.css | 58 +++ Arc/React/.frontend/index.html | 18 + Arc/React/.frontend/index.tsx | 24 ++ Arc/React/.frontend/tsconfig.json | 21 ++ Arc/React/.frontend/tsconfig.node.json | 6 + Arc/React/.frontend/vite.config.ts | 52 +++ .../Arc.React.Specs/Arc.React.Specs.csproj | 26 ++ .../with_valid_details.cs | 21 ++ Arc/React/Arc.React.csproj | 27 ++ Arc/React/Ideas/Board/Board.cs | 87 +++++ Arc/React/Ideas/Board/Board.css | 337 ++++++++++++++++++ Arc/React/Ideas/Board/Board.tsx | 121 +++++++ Arc/React/Ideas/Board/BoardViewModel.ts | 25 ++ Arc/React/Ideas/Board/CaptureIdea.ts | 111 ++++++ Arc/React/Ideas/Board/CaptureIdeaDialog.tsx | 33 ++ Arc/React/Ideas/Board/Idea.ts | 33 ++ Arc/React/Ideas/Board/IdeaId.cs | 30 ++ Arc/React/Ideas/Board/IdeaSummary.cs | 37 ++ Arc/React/Ideas/Board/IdeaTitle.cs | 37 ++ Arc/React/Ideas/Board/ObserveIdeas.ts | 82 +++++ .../by_title_or_summary.ts | 30 ++ Arc/React/Ideas/Board/index.ts | 3 + Arc/React/Program.cs | 24 ++ Arc/React/Properties/launchSettings.json | 14 + Arc/React/README.md | 153 ++++++++ Arc/React/eslint.config.mjs | 48 +++ Arc/React/global.d.ts | 10 + Arc/React/tsconfig.json | 3 + 29 files changed, 1489 insertions(+) create mode 100644 Arc/React/.frontend/App.tsx create mode 100644 Arc/React/.frontend/index.css create mode 100644 Arc/React/.frontend/index.html create mode 100644 Arc/React/.frontend/index.tsx create mode 100644 Arc/React/.frontend/tsconfig.json create mode 100644 Arc/React/.frontend/tsconfig.node.json create mode 100644 Arc/React/.frontend/vite.config.ts create mode 100644 Arc/React/Arc.React.Specs/Arc.React.Specs.csproj create mode 100644 Arc/React/Arc.React.Specs/for_IdeaStore/when_capturing_an_idea/with_valid_details.cs create mode 100644 Arc/React/Arc.React.csproj create mode 100644 Arc/React/Ideas/Board/Board.cs create mode 100644 Arc/React/Ideas/Board/Board.css create mode 100644 Arc/React/Ideas/Board/Board.tsx create mode 100644 Arc/React/Ideas/Board/BoardViewModel.ts create mode 100644 Arc/React/Ideas/Board/CaptureIdea.ts create mode 100644 Arc/React/Ideas/Board/CaptureIdeaDialog.tsx create mode 100644 Arc/React/Ideas/Board/Idea.ts create mode 100644 Arc/React/Ideas/Board/IdeaId.cs create mode 100644 Arc/React/Ideas/Board/IdeaSummary.cs create mode 100644 Arc/React/Ideas/Board/IdeaTitle.cs create mode 100644 Arc/React/Ideas/Board/ObserveIdeas.ts create mode 100644 Arc/React/Ideas/Board/for_BoardViewModel/when_filtering_ideas/by_title_or_summary.ts create mode 100644 Arc/React/Ideas/Board/index.ts create mode 100644 Arc/React/Program.cs create mode 100644 Arc/React/Properties/launchSettings.json create mode 100644 Arc/React/README.md create mode 100644 Arc/React/eslint.config.mjs create mode 100644 Arc/React/global.d.ts create mode 100644 Arc/React/tsconfig.json diff --git a/Arc/React/.frontend/App.tsx b/Arc/React/.frontend/App.tsx new file mode 100644 index 00000000..89f364e2 --- /dev/null +++ b/Arc/React/.frontend/App.tsx @@ -0,0 +1,18 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { Arc } from '@cratis/arc.react'; +import { DialogComponents } from '@cratis/arc.react/dialogs'; +import { CratisComponentsProvider } from '@cratis/components/Common'; +import { BusyIndicatorDialog, ConfirmationDialog } from '@cratis/components/Dialogs'; +import { Board } from '../Ideas/Board/Board'; + +export const App = () => ( + + + + + + + +); diff --git a/Arc/React/.frontend/index.css b/Arc/React/.frontend/index.css new file mode 100644 index 00000000..2c2e6740 --- /dev/null +++ b/Arc/React/.frontend/index.css @@ -0,0 +1,58 @@ +/* Copyright (c) Cratis. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ + +:root { + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: var(--text-color); + background: var(--surface-ground); + font-synthesis: none; + text-rendering: optimizeLegibility; +} + +* { + box-sizing: border-box; +} + +html { + min-width: 320px; + min-height: 100%; + background: var(--surface-ground); +} + +body { + min-width: 320px; + min-height: 100vh; + margin: 0; + background: + radial-gradient(circle at 86% 7%, color-mix(in srgb, var(--primary-color) 15%, transparent), transparent 32rem), + radial-gradient(circle at 8% 44%, color-mix(in srgb, var(--highlight-bg) 60%, transparent), transparent 36rem), + var(--surface-ground); +} + +button, +input, +textarea { + font: inherit; +} + +button, +a { + -webkit-tap-highlight-color: transparent; +} + +#root { + min-height: 100vh; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} diff --git a/Arc/React/.frontend/index.html b/Arc/React/.frontend/index.html new file mode 100644 index 00000000..8046e63b --- /dev/null +++ b/Arc/React/.frontend/index.html @@ -0,0 +1,18 @@ + + + + + + + + + Idea Loom · Arc + React + + +
+ + + diff --git a/Arc/React/.frontend/index.tsx b/Arc/React/.frontend/index.tsx new file mode 100644 index 00000000..20c042f6 --- /dev/null +++ b/Arc/React/.frontend/index.tsx @@ -0,0 +1,24 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import '@cratis/components/tokens'; +import '@cratis/components/styles'; +import 'primeicons/primeicons.css'; +import 'primereact/resources/primereact.min.css'; +import 'primereact/resources/themes/lara-light-blue/theme.css'; +import 'reflect-metadata'; +import './index.css'; +import { Bindings } from '@cratis/arc.react.mvvm'; +import { configure as configureMobx } from 'mobx'; +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { App } from './App'; + +Bindings.initialize(); +configureMobx({ enforceActions: 'never' }); + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +); diff --git a/Arc/React/.frontend/tsconfig.json b/Arc/React/.frontend/tsconfig.json new file mode 100644 index 00000000..123d0166 --- /dev/null +++ b/Arc/React/.frontend/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../.frontend/tsconfig.base.json", + "compilerOptions": { + "allowImportingTsExtensions": true, + "baseUrl": "..", + "moduleResolution": "bundler", + "types": [ + "chai", + "react", + "react-dom", + "vitest/globals" + ] + }, + "include": [ + "../**/*.ts", + "../**/*.tsx" + ], + "exclude": [ + "vite.config.ts" + ] +} diff --git a/Arc/React/.frontend/tsconfig.node.json b/Arc/React/.frontend/tsconfig.node.json new file mode 100644 index 00000000..bfe32424 --- /dev/null +++ b/Arc/React/.frontend/tsconfig.node.json @@ -0,0 +1,6 @@ +{ + "extends": "../../../.frontend/tsconfig.node.json", + "include": [ + "vite.config.ts" + ] +} diff --git a/Arc/React/.frontend/vite.config.ts b/Arc/React/.frontend/vite.config.ts new file mode 100644 index 00000000..f91387dd --- /dev/null +++ b/Arc/React/.frontend/vite.config.ts @@ -0,0 +1,52 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/// + +import { EmitMetadataPlugin } from '@cratis/arc.vite'; +import react from '@vitejs/plugin-react'; +import { fileURLToPath } from 'node:url'; +import { defineConfig, type PluginOption } from 'vite'; + +const backend = 'http://localhost:5064'; + +export default defineConfig({ + root: fileURLToPath(new URL('./', import.meta.url)), + build: { + outDir: '../wwwroot', + emptyOutDir: true, + assetsDir: 'assets', + target: 'esnext', + modulePreload: false, + chunkSizeWarningLimit: 700, + }, + plugins: [ + react(), + // SAFETY: Arc and this app share Vite's plugin lifecycle; the cast only bridges their bundled Vite type identities. + EmitMetadataPlugin({ + tsconfigPath: fileURLToPath(new URL('./tsconfig.json', import.meta.url)), + }) as unknown as PluginOption, + ], + server: { + host: true, + port: 5173, + open: false, + proxy: { + '/api': { + target: backend, + ws: true, + }, + '/.cratis': { + target: backend, + ws: true, + }, + }, + }, + test: { + globals: true, + environment: 'node', + include: ['../**/for_*/when_*/**/*.ts', '../**/for_*/when_*.ts'], + exclude: ['../wwwroot/**', '../bin/**', '../obj/**', '../node_modules/**'], + setupFiles: fileURLToPath(new URL('../../../.frontend/vitest.setup.ts', import.meta.url)), + }, +}); diff --git a/Arc/React/Arc.React.Specs/Arc.React.Specs.csproj b/Arc/React/Arc.React.Specs/Arc.React.Specs.csproj new file mode 100644 index 00000000..35204bfb --- /dev/null +++ b/Arc/React/Arc.React.Specs/Arc.React.Specs.csproj @@ -0,0 +1,26 @@ + + + false + true + Arc.React.Specs + + $(NoWarn);CA1001;IDE1006;NU1506;SA1134 + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + CratisProxiesOutputPath= + + + diff --git a/Arc/React/Arc.React.Specs/for_IdeaStore/when_capturing_an_idea/with_valid_details.cs b/Arc/React/Arc.React.Specs/for_IdeaStore/when_capturing_an_idea/with_valid_details.cs new file mode 100644 index 00000000..23139bde --- /dev/null +++ b/Arc/React/Arc.React.Specs/for_IdeaStore/when_capturing_an_idea/with_valid_details.cs @@ -0,0 +1,21 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Arc.React.Specs.for_IdeaStore.when_capturing_an_idea; + +using Arc.React.Ideas.Board; +using Cratis.Specifications; +using Xunit; + +public class with_valid_details : Specification +{ + readonly IdeaStore _store = new(); + readonly IdeaId _ideaId = IdeaId.New(); + + void Because() => new CaptureIdea(_ideaId, "Make setup visible", "Show the shortest path from clone to a running slice.").Handle(_store); + void Destroy() => _store.Dispose(); + + [Fact] void should_add_one_idea() => _store.Current.Count.ShouldEqual(1); + [Fact] void should_keep_the_assigned_identifier() => _store.Current.Single().Id.ShouldEqual(_ideaId); + [Fact] void should_keep_the_title() => _store.Current.Single().Title.ShouldEqual((IdeaTitle)"Make setup visible"); +} diff --git a/Arc/React/Arc.React.csproj b/Arc/React/Arc.React.csproj new file mode 100644 index 00000000..e032a19a --- /dev/null +++ b/Arc/React/Arc.React.csproj @@ -0,0 +1,27 @@ + + + Arc.React + Arc.React + true + true + true + Exe + + $(NoWarn);CA1515;NU1506 + $(MSBuildThisFileDirectory) + 2 + true + true + + + + + + + + + + + + + diff --git a/Arc/React/Ideas/Board/Board.cs b/Arc/React/Ideas/Board/Board.cs new file mode 100644 index 00000000..bde20df6 --- /dev/null +++ b/Arc/React/Ideas/Board/Board.cs @@ -0,0 +1,87 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Arc.React.Ideas.Board; + +using System.Reactive.Subjects; +using System.Threading; +using Cratis.Arc.Authorization; +using Cratis.Arc.Commands.ModelBound; +using Cratis.Arc.Queries.ModelBound; + +/// +/// Captures a new idea in the current-state board. +/// +/// The identifier assigned by the client. +/// The concise idea title. +/// The detail that makes the idea useful. +[Command, AllowAnonymous] +public record CaptureIdea(IdeaId Id, IdeaTitle Title, IdeaSummary Summary) +{ + /// + /// Stores the idea directly without appending an event. + /// + /// The current-state idea store. + public void Handle(IdeaStore store) => store.Capture(new(Id, Title, Summary)); +} + +/// +/// Represents an idea shown on the board. +/// +/// The idea identifier. +/// The concise idea title. +/// The useful idea detail. +[ReadModel, AllowAnonymous] +public record Idea(IdeaId Id, IdeaTitle Title, IdeaSummary Summary) +{ + /// + /// Observes all captured ideas and pushes the current board on each change. + /// + /// The current-state idea store. + /// A live sequence containing the current ideas. + public static ISubject> ObserveIdeas(IdeaStore store) => store.Observe(); +} + +/// +/// Holds the current idea board in memory for this focused sample. +/// +public sealed class IdeaStore : IDisposable +{ + readonly Lock _gate = new(); + readonly BehaviorSubject> _ideas = new(Array.Empty()); + + /// + /// Gets a snapshot of the current ideas. + /// + public IReadOnlyList Current + { + get + { + lock (_gate) + { + return _ideas.Value.ToArray(); + } + } + } + + /// + /// Captures an idea and publishes a fresh board snapshot. + /// + /// The idea to capture. + public void Capture(Idea idea) + { + lock (_gate) + { + _ideas.OnNext(new[] { idea }.Concat(_ideas.Value).ToArray()); + } + } + + /// + /// Observes the current board and every subsequent change. + /// + /// The live idea sequence. + public ISubject> Observe() => _ideas; + + /// + public void Dispose() => _ideas.Dispose(); +} diff --git a/Arc/React/Ideas/Board/Board.css b/Arc/React/Ideas/Board/Board.css new file mode 100644 index 00000000..6b223f14 --- /dev/null +++ b/Arc/React/Ideas/Board/Board.css @@ -0,0 +1,337 @@ +/* Copyright (c) Cratis. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ + +.idea-loom { + width: min(1180px, calc(100% - 2rem)); + min-height: 100vh; + margin: 0 auto; + padding: 1.25rem 0 2rem; +} + +.app-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 0 2.75rem; +} + +.brand { + display: inline-flex; + gap: 0.7rem; + align-items: center; + color: var(--text-color); + font-weight: 750; + letter-spacing: -0.02em; + text-decoration: none; +} + +.brand__mark { + display: grid; + width: 2.4rem; + height: 2.4rem; + place-items: center; + color: var(--primary-color-text); + background: var(--primary-color); + border-radius: 0.8rem; + box-shadow: 0 0.6rem 1.6rem color-mix(in srgb, var(--primary-color) 25%, transparent); + font-size: 0.78rem; + letter-spacing: -0.04em; +} + +.architecture-pill { + display: inline-flex; + gap: 0.45rem; + align-items: center; + padding: 0.55rem 0.85rem; + color: var(--text-color-secondary); + background: color-mix(in srgb, var(--surface-card) 78%, transparent); + border: 1px solid var(--surface-border); + border-radius: 999px; + backdrop-filter: blur(14px); + font-size: 0.8rem; + font-weight: 650; +} + +.architecture-pill i { + color: var(--primary-color); +} + +.hero { + display: grid; + grid-template-columns: minmax(0, 1.3fr) minmax(17rem, 0.7fr); + gap: clamp(2rem, 8vw, 8rem); + align-items: end; + padding: clamp(1rem, 4vw, 3.5rem) 0 clamp(3.5rem, 8vw, 6.5rem); +} + +.eyebrow { + display: inline-block; + margin-bottom: 0.8rem; + color: var(--primary-color); + font-size: 0.74rem; + font-weight: 800; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.hero h1 { + max-width: 12ch; + margin: 0; + color: var(--text-color); + font-size: clamp(3.5rem, 8vw, 7.25rem); + font-weight: 780; + letter-spacing: -0.075em; + line-height: 0.88; +} + +.hero h1 em { + color: var(--primary-color); + font-family: Georgia, "Times New Roman", serif; + font-weight: 500; +} + +.hero__copy > p { + max-width: 38rem; + margin: 1.75rem 0 0; + color: var(--text-color-secondary); + font-size: clamp(1rem, 1.5vw, 1.18rem); + line-height: 1.7; +} + +.hero__metrics { + display: grid; + gap: 0; + overflow: hidden; + background: color-mix(in srgb, var(--surface-card) 88%, transparent); + border: 1px solid var(--surface-border); + border-radius: 1.35rem; + box-shadow: 0 1.5rem 4rem color-mix(in srgb, var(--surface-900) 8%, transparent); + backdrop-filter: blur(18px); +} + +.hero__metrics div { + display: flex; + align-items: baseline; + justify-content: space-between; + padding: 1.15rem 1.35rem; + border-bottom: 1px solid var(--surface-border); +} + +.hero__metrics div:last-child { + border-bottom: 0; +} + +.hero__metrics strong { + color: var(--text-color); + font-size: 1.15rem; +} + +.hero__metrics span { + color: var(--text-color-secondary); + font-size: 0.78rem; +} + +.board-surface { + padding: clamp(1.25rem, 3vw, 2.25rem); + background: color-mix(in srgb, var(--surface-card) 92%, transparent); + border: 1px solid var(--surface-border); + border-radius: 1.6rem; + box-shadow: 0 1.8rem 5rem color-mix(in srgb, var(--surface-900) 9%, transparent); + backdrop-filter: blur(20px); +} + +.board-surface__heading { + display: flex; + gap: 1rem; + align-items: flex-start; + justify-content: space-between; +} + +.board-surface__heading h2 { + margin: 0; + color: var(--text-color); + font-size: clamp(1.65rem, 3vw, 2.4rem); + letter-spacing: -0.045em; +} + +.search-box { + display: flex; + gap: 0.65rem; + align-items: center; + width: min(30rem, 100%); + margin: 1.6rem 0; + padding: 0.75rem 0.95rem; + color: var(--text-color-secondary); + background: var(--surface-ground); + border: 1px solid var(--surface-border); + border-radius: 0.9rem; + transition: border-color 150ms ease, box-shadow 150ms ease; +} + +.search-box:focus-within { + border-color: var(--primary-color); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color) 14%, transparent); +} + +.search-box input { + width: 100%; + color: var(--text-color); + background: transparent; + border: 0; + outline: 0; +} + +.search-box input::placeholder { + color: var(--text-color-secondary); +} + +.idea-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.85rem; +} + +.idea-card { + display: grid; + grid-template-columns: auto 1fr; + gap: 1rem; + min-height: 10rem; + padding: 1.15rem; + background: var(--surface-card); + border: 1px solid var(--surface-border); + border-radius: 1rem; + transition: border-color 160ms ease, transform 160ms ease, box-shadow 160ms ease; +} + +.idea-card:hover { + border-color: color-mix(in srgb, var(--primary-color) 40%, var(--surface-border)); + box-shadow: 0 0.9rem 2.3rem color-mix(in srgb, var(--surface-900) 7%, transparent); + transform: translateY(-2px); +} + +.idea-card__number { + display: grid; + width: 2.5rem; + height: 2.5rem; + place-items: center; + color: var(--primary-color); + background: var(--highlight-bg); + border-radius: 0.75rem; + font-size: 0.73rem; + font-weight: 800; +} + +.idea-card h3 { + margin: 0.25rem 0 0.6rem; + color: var(--text-color); + font-size: 1.1rem; + letter-spacing: -0.02em; +} + +.idea-card p { + margin: 0; + color: var(--text-color-secondary); + line-height: 1.62; +} + +.empty-state, +.board-state { + display: grid; + min-height: 18rem; + place-items: center; + align-content: center; + padding: 2rem; + color: var(--text-color-secondary); + text-align: center; + background: color-mix(in srgb, var(--surface-ground) 68%, transparent); + border: 1px dashed var(--surface-border); + border-radius: 1rem; +} + +.empty-state__icon { + display: grid; + width: 3.6rem; + height: 3.6rem; + margin-bottom: 1rem; + place-items: center; + color: var(--primary-color); + background: var(--highlight-bg); + border-radius: 1.1rem; + font-size: 1.35rem; +} + +.empty-state h3 { + margin: 0 0 0.45rem; + color: var(--text-color); + font-size: 1.35rem; +} + +.empty-state p { + max-width: 32rem; + margin: 0 0 1.25rem; + line-height: 1.6; +} + +.empty-state button { + padding: 0.75rem 1rem; + color: var(--primary-color-text); + background: var(--primary-color); + border: 0; + border-radius: 0.75rem; + box-shadow: 0 0.7rem 1.7rem color-mix(in srgb, var(--primary-color) 22%, transparent); + cursor: pointer; + font-weight: 700; +} + +.capture-idea__intro { + margin: 0 0 1.1rem; + color: var(--text-color-secondary); + line-height: 1.55; +} + +.idea-loom footer { + display: flex; + flex-wrap: wrap; + gap: 0.65rem 1.4rem; + padding: 1.4rem 0 0; + color: var(--text-color-secondary); + font-size: 0.75rem; +} + +.idea-loom footer span::before { + margin-right: 0.5rem; + color: var(--primary-color); + content: "•"; +} + +@media (max-width: 760px) { + .idea-loom { + width: min(100% - 1rem, 1180px); + padding-top: 0.7rem; + } + + .architecture-pill { + max-width: 11rem; + justify-content: center; + text-align: center; + } + + .hero { + grid-template-columns: 1fr; + gap: 2.5rem; + padding-bottom: 3.5rem; + } + + .hero h1 { + font-size: clamp(3.25rem, 18vw, 5rem); + } + + .board-surface__heading { + flex-direction: column; + } + + .idea-grid { + grid-template-columns: 1fr; + } +} diff --git a/Arc/React/Ideas/Board/Board.tsx b/Arc/React/Ideas/Board/Board.tsx new file mode 100644 index 00000000..f6f0c62c --- /dev/null +++ b/Arc/React/Ideas/Board/Board.tsx @@ -0,0 +1,121 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { useDialog } from '@cratis/arc.react/dialogs'; +import { withViewModel } from '@cratis/arc.react.mvvm'; +import { Toolbar, ToolbarButton } from '@cratis/components/Toolbar'; +import { BoardViewModel } from './BoardViewModel'; +import { CaptureIdeaDialog } from './CaptureIdeaDialog'; +import { Idea } from './Idea'; +import { ObserveIdeas } from './ObserveIdeas'; +import './Board.css'; + +interface IdeaCardProps { + idea: Idea; + sequence: number; +} + +const IdeaCard = ({ idea, sequence }: IdeaCardProps) => ( +
+
{String(sequence).padStart(2, '0')}
+
+

{idea.title}

+

{idea.summary}

+
+
+); + +export const Board = withViewModel(BoardViewModel, ({ viewModel }) => { + const [ideasResult] = ObserveIdeas.use(); + const [CaptureDialog, showCaptureDialog] = useDialog(CaptureIdeaDialog); + const ideas = viewModel.filter(ideasResult.data ?? []); + const capturedCount = ideasResult.data?.length ?? 0; + + return ( +
+
+ + IL + Idea Loom + + Arc CQRS · no Chronicle +
+ +
+
+ A focused Arc + React sample +

Shape the next
small win.

+

+ Capture a useful idea, watch the live query update, and trace one strongly typed contract from C# to React. +

+
+
+
{capturedCount}ideas captured
+
Liveobservable query
+
Directcurrent-state write
+
+
+ +
+
+
+ Working set +

Ideas worth a conversation

+
+ + { void showCaptureDialog(); }} + /> + +
+ +