diff --git a/Documentation/chronicle/event-store-subscriptions.md b/Documentation/chronicle/event-store-subscriptions.md new file mode 100644 index 0000000..51da381 --- /dev/null +++ b/Documentation/chronicle/event-store-subscriptions.md @@ -0,0 +1,91 @@ +# Event Store Subscriptions + +Event store subscriptions let one event store consume events produced in another event store. You configure subscriptions on a target event store and point each subscription to a source event store and a set of event types. + +## list + +Lists all event store subscriptions configured for the target event store. + +```bash +cratis chronicle event-store-subscriptions list --event-store +``` + +### Examples + +List subscriptions for the `system` event store: + +```bash +cratis chronicle event-store-subscriptions list --event-store system +``` + +List in plain format: + +```bash +cratis chronicle event-store-subscriptions list --event-store system --output plain +``` + +## add + +Adds an event store subscription to the target event store. + +```bash +cratis chronicle event-store-subscriptions add --event-store +``` + +### Arguments + +| Argument | Description | +|---|---| +| `SUBSCRIPTION_ID` | Unique identifier for the subscription. | +| `SOURCE_EVENT_STORE` | Event store to subscribe from. | +| `EVENT_TYPES` | Comma-separated list of event type IDs to include. | + +### Examples + +Add a subscription from `default` to `system` for one event type: + +```bash +cratis chronicle event-store-subscriptions add orders-from-default default MyCompany.Sales.OrderPlaced --event-store system +``` + +Add a subscription with multiple event types: + +```bash +cratis chronicle event-store-subscriptions add sales-feed default MyCompany.Sales.OrderPlaced,MyCompany.Sales.OrderCancelled --event-store system +``` + +## remove + +Removes an event store subscription from the target event store. + +```bash +cratis chronicle event-store-subscriptions remove --event-store +``` + +The command prompts for confirmation before proceeding. Pass `--yes` to skip the prompt in automated workflows. + +### Arguments + +| Argument | Description | +|---|---| +| `SUBSCRIPTION_ID` | Identifier of the subscription to remove. Use `event-store-subscriptions list` to retrieve IDs. | + +### Options + +| Flag | Description | +|---|---| +| `-y, --yes` | Skip confirmation prompt. | + +### Examples + +Remove a subscription interactively: + +```bash +cratis chronicle event-store-subscriptions remove orders-from-default --event-store system +``` + +Remove without confirmation: + +```bash +cratis chronicle event-store-subscriptions remove orders-from-default --event-store system --yes +``` diff --git a/Documentation/chronicle/index.md b/Documentation/chronicle/index.md index 82c3024..56b27df 100644 --- a/Documentation/chronicle/index.md +++ b/Documentation/chronicle/index.md @@ -31,6 +31,7 @@ Commands that operate within a specific event store or namespace accept the foll | `event-types` | List and inspect registered event type definitions. | | `events` | Query events from an event sequence or retrieve the tail sequence number. | | `observers` | List, inspect, replay, and retry observers. | +| `event-store-subscriptions` | Manage cross-event-store subscriptions for event forwarding. | | `failed-partitions` | List and inspect partitions where an observer has failed. | | `projections` | List and inspect projection definitions. | | `read-models` | List, query, and inspect read model instances and snapshots. | diff --git a/Documentation/chronicle/toc.yml b/Documentation/chronicle/toc.yml index 2451d78..c6e727e 100644 --- a/Documentation/chronicle/toc.yml +++ b/Documentation/chronicle/toc.yml @@ -10,6 +10,8 @@ href: events.md - name: Observers href: observers.md +- name: Event Store Subscriptions + href: event-store-subscriptions.md - name: Failed Partitions href: failed-partitions.md - name: Projections diff --git a/Integration/Chronicle/for_EventStoreSubscriptions/when_adding_and_removing_event_store_subscription.cs b/Integration/Chronicle/for_EventStoreSubscriptions/when_adding_and_removing_event_store_subscription.cs new file mode 100644 index 0000000..f9c0c19 --- /dev/null +++ b/Integration/Chronicle/for_EventStoreSubscriptions/when_adding_and_removing_event_store_subscription.cs @@ -0,0 +1,53 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using context = Cratis.Cli.Integration.Chronicle.for_EventStoreSubscriptions.when_adding_and_removing_event_store_subscription.context; + +namespace Cratis.Cli.Integration.Chronicle.for_EventStoreSubscriptions; + +[Collection(ChronicleCollection.Name)] +public class when_adding_and_removing_event_store_subscription(context context) : CliGiven(context) +{ + public class context : given.a_connected_cli + { + public CliCommandResult AddResult = null!; + public CliCommandResult RemoveResult = null!; + public bool SubscriptionAppearedInList; + public string SubscriptionId = null!; + + async Task Because() + { + SubscriptionId = $"integration-test-subscription-{Guid.NewGuid():N}"; + AddResult = await RunCliAsync( + "chronicle", + "event-store-subscriptions", + "add", + SubscriptionId, + "system", + "Integration.Test.EventType", + "--event-store", + "system"); + + var listResult = await RunCliAsync("chronicle", "event-store-subscriptions", "list", "--event-store", "system"); + var subscriptions = JsonDocument.Parse(listResult.StandardOutput).RootElement; + var testSubscription = subscriptions.EnumerateArray() + .FirstOrDefault(subscription => subscription.GetProperty("identifier").GetString() == SubscriptionId); + SubscriptionAppearedInList = testSubscription.ValueKind != JsonValueKind.Undefined; + + if (SubscriptionAppearedInList) + { + RemoveResult = await RunCliAsync("chronicle", "event-store-subscriptions", "remove", SubscriptionId, "--event-store", "system", "--yes"); + } + } + } + + [Fact] void should_return_success_for_add() => Context.AddResult.ExitCode.ShouldEqual(ExitCodes.Success); + + [Fact] void should_contain_added_message() => Context.AddResult.StandardOutput.ShouldContain("added"); + + [Fact] void should_show_subscription_in_list() => Context.SubscriptionAppearedInList.ShouldBeTrue(); + + [Fact] void should_return_success_for_remove() => Context.RemoveResult.ExitCode.ShouldEqual(ExitCodes.Success); + + [Fact] void should_contain_removed_message() => Context.RemoveResult.StandardOutput.ShouldContain("removed"); +} diff --git a/Source/Cli/Commands/Chronicle/EventStoreSubscriptions/AddEventStoreSubscriptionCommand.cs b/Source/Cli/Commands/Chronicle/EventStoreSubscriptions/AddEventStoreSubscriptionCommand.cs new file mode 100644 index 0000000..135282f --- /dev/null +++ b/Source/Cli/Commands/Chronicle/EventStoreSubscriptions/AddEventStoreSubscriptionCommand.cs @@ -0,0 +1,45 @@ +// 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.Contracts.Observation.EventStoreSubscriptions; + +namespace Cratis.Cli.Commands.Chronicle.EventStoreSubscriptions; + +/// +/// Adds an event store subscription. +/// +[LlmDescription("Adds an event store subscription to a target event store.")] +[CliCommand("add", "Add an event store subscription", Branch = typeof(ChronicleBranch.EventStoreSubscriptions))] +[CliExample("chronicle", "event-store-subscriptions", "add", "orders-from-default", "default", "MyCompany.Sales.OrderPlaced")] +[LlmOutputAdvice("plain", "Plain outputs a simple confirmation message.")] +[LlmOption("", "string", "The unique subscription identifier (positional)")] +[LlmOption("", "string", "The source event store to subscribe to (positional)")] +[LlmOption("", "string", "Comma-separated event types to include in the subscription (positional)")] +public class AddEventStoreSubscriptionCommand : ChronicleCommand +{ + /// + protected override async Task ExecuteCommandAsync(IServices services, AddEventStoreSubscriptionSettings settings, string format) + { + var eventTypes = settings.EventTypes + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(eventType => new Cratis.Chronicle.Contracts.Events.EventType { Id = eventType }) + .ToList(); + + await services.EventStoreSubscriptions.Add(new AddEventStoreSubscriptions + { + TargetEventStore = settings.ResolveEventStore(), + Subscriptions = + [ + new EventStoreSubscriptionDefinition + { + Identifier = settings.SubscriptionId, + SourceEventStore = settings.SourceEventStore, + EventTypes = eventTypes + } + ] + }); + + OutputFormatter.WriteMessage(format, $"Event store subscription '{settings.SubscriptionId}' added."); + return ExitCodes.Success; + } +} diff --git a/Source/Cli/Commands/Chronicle/EventStoreSubscriptions/AddEventStoreSubscriptionSettings.cs b/Source/Cli/Commands/Chronicle/EventStoreSubscriptions/AddEventStoreSubscriptionSettings.cs new file mode 100644 index 0000000..293917b --- /dev/null +++ b/Source/Cli/Commands/Chronicle/EventStoreSubscriptions/AddEventStoreSubscriptionSettings.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 Cratis.Cli.Commands.Chronicle.EventStoreSubscriptions; + +/// +/// Settings for adding an event store subscription. +/// +public class AddEventStoreSubscriptionSettings : EventStoreSettings +{ + /// + /// Gets or sets the subscription identifier. + /// + [CommandArgument(0, "")] + [Description("Unique identifier for the subscription")] + public string SubscriptionId { get; set; } = string.Empty; + + /// + /// Gets or sets the source event store. + /// + [CommandArgument(1, "")] + [Description("Source event store to subscribe from")] + public string SourceEventStore { get; set; } = string.Empty; + + /// + /// Gets or sets a comma-separated list of event types. + /// + [CommandArgument(2, "")] + [Description("Comma-separated event types to subscribe to")] + public string EventTypes { get; set; } = string.Empty; +} diff --git a/Source/Cli/Commands/Chronicle/EventStoreSubscriptions/ListEventStoreSubscriptionsCommand.cs b/Source/Cli/Commands/Chronicle/EventStoreSubscriptions/ListEventStoreSubscriptionsCommand.cs new file mode 100644 index 0000000..c75e434 --- /dev/null +++ b/Source/Cli/Commands/Chronicle/EventStoreSubscriptions/ListEventStoreSubscriptionsCommand.cs @@ -0,0 +1,38 @@ +// 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.Contracts.Observation.EventStoreSubscriptions; + +namespace Cratis.Cli.Commands.Chronicle.EventStoreSubscriptions; + +/// +/// Lists event store subscriptions for a target event store. +/// +[LlmDescription("Lists event store subscriptions configured for the target event store.")] +[CliCommand("list", "List event store subscriptions", Branch = typeof(ChronicleBranch.EventStoreSubscriptions))] +[CliExample("chronicle", "event-store-subscriptions", "list", "--event-store", "system")] +[LlmOutputAdvice("plain", "Use plain for consistency with other listing commands.")] +public class ListEventStoreSubscriptionsCommand : ChronicleCommand +{ + /// + protected override async Task ExecuteCommandAsync(IServices services, EventStoreSettings settings, string format) + { + var subscriptions = await services.EventStoreSubscriptions.GetSubscriptions(new GetEventStoreSubscriptionsRequest + { + TargetEventStore = settings.ResolveEventStore() + }); + + OutputFormatter.Write( + format, + subscriptions, + ["Identifier", "SourceEventStore", "EventTypes"], + subscription => + [ + subscription.Identifier, + subscription.SourceEventStore, + string.Join(", ", (subscription.EventTypes ?? []).Select(eventType => eventType.Id)) + ]); + + return ExitCodes.Success; + } +} diff --git a/Source/Cli/Commands/Chronicle/EventStoreSubscriptions/RemoveEventStoreSubscriptionCommand.cs b/Source/Cli/Commands/Chronicle/EventStoreSubscriptions/RemoveEventStoreSubscriptionCommand.cs new file mode 100644 index 0000000..f8f33c4 --- /dev/null +++ b/Source/Cli/Commands/Chronicle/EventStoreSubscriptions/RemoveEventStoreSubscriptionCommand.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.Contracts.Observation.EventStoreSubscriptions; + +namespace Cratis.Cli.Commands.Chronicle.EventStoreSubscriptions; + +/// +/// Removes an event store subscription. +/// +[LlmDescription("Removes an event store subscription from a target event store. Destructive — prompts for confirmation unless --yes is specified.")] +[CliCommand("remove", "Remove an event store subscription", Branch = typeof(ChronicleBranch.EventStoreSubscriptions), DynamicCompletion = "event-store-subscriptions")] +[CliExample("chronicle", "event-store-subscriptions", "remove", "orders-from-default")] +[LlmOutputAdvice("plain", "Plain outputs a simple confirmation message.")] +[LlmOption("", "string", "The unique subscription identifier to remove (positional)")] +public class RemoveEventStoreSubscriptionCommand : ChronicleCommand +{ + /// + protected override async Task ExecuteCommandAsync(IServices services, RemoveEventStoreSubscriptionSettings settings, string format) + { + if (!ConfirmationHelper.ShouldProceed(settings, $"Are you sure you want to remove event store subscription '{settings.SubscriptionId}'?")) + { + OutputFormatter.WriteMessage(format, "Aborted."); + return ExitCodes.Success; + } + + await services.EventStoreSubscriptions.Remove(new RemoveEventStoreSubscriptions + { + TargetEventStore = settings.ResolveEventStore(), + SubscriptionIds = [settings.SubscriptionId] + }); + + OutputFormatter.WriteMessage(format, $"Event store subscription '{settings.SubscriptionId}' removed."); + return ExitCodes.Success; + } +} diff --git a/Source/Cli/Commands/Chronicle/EventStoreSubscriptions/RemoveEventStoreSubscriptionSettings.cs b/Source/Cli/Commands/Chronicle/EventStoreSubscriptions/RemoveEventStoreSubscriptionSettings.cs new file mode 100644 index 0000000..9a2defb --- /dev/null +++ b/Source/Cli/Commands/Chronicle/EventStoreSubscriptions/RemoveEventStoreSubscriptionSettings.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 Cratis.Cli.Commands.Chronicle.EventStoreSubscriptions; + +/// +/// Settings for removing an event store subscription. +/// +public class RemoveEventStoreSubscriptionSettings : EventStoreSettings +{ + /// + /// Gets or sets the subscription identifier. + /// + [CommandArgument(0, "")] + [Description("Subscription identifier (from 'cratis chronicle event-store-subscriptions list')")] + public string SubscriptionId { get; set; } = string.Empty; +} diff --git a/Source/Cli/Commands/Completions/DynamicCompleteCommand.cs b/Source/Cli/Commands/Completions/DynamicCompleteCommand.cs index 53b4788..79ee389 100644 --- a/Source/Cli/Commands/Completions/DynamicCompleteCommand.cs +++ b/Source/Cli/Commands/Completions/DynamicCompleteCommand.cs @@ -2,6 +2,7 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using Cratis.Chronicle.Contracts.Jobs; +using Cratis.Chronicle.Contracts.Observation.EventStoreSubscriptions; using Cratis.Cli.Commands.Chronicle; namespace Cratis.Cli.Commands.Completions; @@ -155,6 +156,18 @@ protected override async Task ExecuteCommandAsync(IServices services, Dynam break; + case "event-store-subscriptions": + var subscriptions = await services.EventStoreSubscriptions.GetSubscriptions(new GetEventStoreSubscriptionsRequest + { + TargetEventStore = eventStore + }); + foreach (var subscription in subscriptions ?? []) + { + Console.WriteLine(subscription.Identifier); + } + + break; + case "contexts": var config = CliConfiguration.Load(); foreach (var name in config.Contexts.Keys) diff --git a/Source/Cli/Registration/ChronicleBranch.cs b/Source/Cli/Registration/ChronicleBranch.cs index 564300a..ddfd5a7 100644 --- a/Source/Cli/Registration/ChronicleBranch.cs +++ b/Source/Cli/Registration/ChronicleBranch.cs @@ -9,7 +9,7 @@ namespace Cratis.Cli.Registration; /// Chronicle server commands branch. Contains all sub-branches for event stores, /// observers, events, etc. /// -[CliBranch("chronicle", "Commands for interacting with a Chronicle server. Contains sub-branches for event stores, namespaces, event types, events, observers, projections, read models, jobs, failed partitions, recommendations, identities, auth, users, and applications.")] +[CliBranch("chronicle", "Commands for interacting with a Chronicle server. Contains sub-branches for event stores, namespaces, event types, events, observers, event store subscriptions, projections, read models, jobs, failed partitions, recommendations, identities, auth, users, and applications.")] public static class ChronicleBranch { /// Event store management. @@ -32,6 +32,10 @@ public static class Events { } [CliBranch("observers", "Manage observers (projections, reactors, reducers, client observers). Supports listing, inspecting, replaying, and recovering failed partitions.")] public static class Observers { } + /// Event store subscription management. + [CliBranch("event-store-subscriptions", "Manage event store subscriptions for cross-store event flow. Supports listing, adding, and removing subscriptions.")] + public static class EventStoreSubscriptions { } + /// Failed partition inspection. [CliBranch("failed-partitions", "List and inspect observer partitions that have failed and are paused. Use to diagnose and recover from processing failures.")] public static class FailedPartitions { }