diff --git a/.agents/skills/fcommand/SKILL.md b/.agents/skills/fcommand/SKILL.md new file mode 100644 index 0000000..3e7cff2 --- /dev/null +++ b/.agents/skills/fcommand/SKILL.md @@ -0,0 +1,59 @@ +--- +name: fcommand +description: Use whenever generating or reviewing F# code that defines, serializes, parses, sends, or handles commands with the Alma.Command library — e.g. calling Command.toDto, Command.parse, CommandDto.serialize, CommandResponse.create/parse/toDto, or composing a CommandHandler (CommandHandler.create/handle/handleWith). Trigger also on mentions of Reactor, Requestor, ReplyTo, TimeToLive, DataItem, RawData, GenericMetaData, CommandResponseCreated events, TTL/Reactor command validation, Spot/Zone/Bucket resolution, or the StartProcess prepared command. Applies to CQRS command flow over Kafka or HTTP in the Alma ecosystem. +--- + +# F-Command + +Library: [alma-oss/fcommand](https://github.com/alma-oss/fcommand) NuGet: `Alma.Command` + +## Purpose + +`Alma.Command` provides generic, strongly-typed Command and CommandResponse types plus the infrastructure to serialize them to DTOs/JSON, parse them back via JSON type providers, and dispatch them through a validating CommandHandler. It implements a CQRS/event-driven command flow where commands are routed to a target service (Reactor) and answered synchronously (HTTP) or asynchronously (e.g. Kafka). + +## When to Use + +- Defining a new typed command module (request name + typed `Data`) for a service. +- Serializing a command to JSON before sending, or parsing an incoming command/response. +- Building a command handler that validates and dispatches an incoming command. +- Producing a `CommandResponse`, or deriving a `CommandResponseCreated` event from one. + +## When NOT to Use + +- Plain event publishing/consuming without command semantics (use the Kafka event library directly). +- Service identity/routing primitives themselves (those come from `Alma.ServiceIdentification`). +- Generic JSON serialization unrelated to commands (use `Alma.Serializer`). + +## Main Concepts + +- **`Command<'MetaData, 'CommandData>`** — generic command record carrying envelope fields plus typed `MetaData` and `Data`. +- **`CommandDto<'MetaDataDto, 'DataDto>`** — wire/JSON shape of a command; produced via `Command.toDto`. +- **`Data<'CommandData>` / `DataItem<'Value>`** — typed payload wrappers; `DataItem` pairs a value with a `Type` tag. +- **`Reactor`** — target handler address as a `BoxPattern` (supports `*` wildcards). +- **`Requestor`** — sender identity as a concrete `Box`. +- **`ReplyTo`** — where the response goes; `ReplyTo.HttpCallerConnection` means synchronous HTTP reply. +- **`TimeToLive`** — command validity window (`TimeToLive.ofSeconds` / `ofMiliSeconds`). +- **`Request`** — validated command-name string (`Request.create` returns a `Result`). +- **`CommandResponse<'MetaData, 'ResponseData>`** — typed response with `StatusCode` and `ResponseError list`. +- **`CommandHandler<...>`** — bundles a `Request`, a command accessor, and an async handler; created with `CommandHandler.create`. +- **`CommandHandleResult`** — outcome DU: `CommandStarted` | `CommandNotStarted` | `CommandResponse`. +- **`RawData`** — untyped JSON wrapper with active patterns (`|Item|`, `|DataItem|`, …) for hand-parsing payloads. +- **`GenericMetaData`** — `Map` metadata used by responses. +- **`Spot` (Zone + Bucket)** — data placement resolved from `Reactor`, falling back to `Requestor` on wildcards. + +## Related Libraries + +- `Alma.ServiceIdentification` — `Box` / `BoxPattern` (Domain/Context/Purpose/Version/Zone/Bucket) used by Reactor/Requestor. +- `Alma.Serializer` — JSON serialization (`Serialize.toJson`, `Serialize.dateTime`). +- `Alma.Kafka` — event envelope types used by the prepared `CommandResponseCreated` event. +- `Feather.ErrorHandling` — `result`/`asyncResult` computation expressions and the `<@>` map-error operator. + +## Keywords for Search + +Alma.Command, fcommand, F# command, CQRS, command handler, Command.toDto, Command.parse, CommandDto.serialize, CommandResponse, CommandResponse.parse, CommandHandler.handle, Reactor, Requestor, ReplyTo, HttpCallerConnection, TimeToLive, TTL validation, DataItem, Data, RawData, GenericMetaData, Spot, Zone, Bucket, BoxPattern, StartProcess, CommandResponseCreated, JSON type provider, DTO serialization + +## Reference Files + +- For composition principles, recommended API usage, error handling, and testing, read `references/preferred-patterns.md`. +- For known pitfalls, outdated assumptions, and incorrect usage, read `references/anti-patterns.md`. +- For worked, runnable code examples (ordered simple to full workflow), read `references/examples.md`. diff --git a/.agents/skills/fcommand/references/anti-patterns.md b/.agents/skills/fcommand/references/anti-patterns.md new file mode 100644 index 0000000..7830214 --- /dev/null +++ b/.agents/skills/fcommand/references/anti-patterns.md @@ -0,0 +1,45 @@ +# Anti-Patterns + +Each entry is **mistake → why → fix**. + +## Command Shape + +- **Treating `Command` as a `Synchronous | Asynchronous` DU.** → Older docs show two separate command cases, but the current API is a single generic record `Command<'MetaData,'CommandData>` that always carries a `ReplyTo` field. → Build the one record type and express sync-vs-async through `ReplyTo` (`ReplyTo.HttpCallerConnection` for synchronous), not through different command types. + +- **Hardcoding `Schema` to an arbitrary number.** → Parsing rejects anything other than schema `1` (`UnsupportedSchema`). → Always set `Schema = 1`. + +## Serialization + +- **Serializing a domain `Command` (or `CommandResponse`) straight to JSON.** → Domain types contain wrapped DUs and are not the wire contract; output will be wrong or fail. → Always go domain → DTO (`Command.toDto` / `CommandResponse.toDto`) → JSON (`CommandDto.serialize` / `Serialize.toJson`). + +- **Returning a plain value from a `toDto` serializer function.** → `Command.toDto` expects both the metadata and data serializers to return `Result<_,_>`; a bare value won't type-check. → Wrap success in `Ok` (e.g. `serializeData >> Ok`). + +- **Formatting timestamps by hand.** → Inconsistent formats break round-tripping through the JSON type providers. → Use `Serialize.dateTime` (from `Alma.Serializer`) for timestamps and `created_at`. + +## Construction & Validation + +- **Building a `Request` directly or assuming `Request.create` succeeds.** → The `Request` constructor is private and `Request.create` returns `Error EmptyRequest` for null/empty input. → Call `Request.create` and handle the `Result`. + +- **Ignoring the result of TTL/Reactor validation in a handler.** → A handler that skips validation will process expired or mis-routed commands. → Let `CommandHandler.handle` (or `handleWith` with explicit `Validations`) run the checks and branch on the returned `CommandHandleResult`. + +- **Expecting a `CommandResponse` from every handler call.** → Only synchronous (`ReplyTo.HttpCallerConnection`) commands return `CommandResponse`; async ones return `CommandStarted` and deliver the response later via the persist callback. → Match all three `CommandHandleResult` cases (`CommandStarted`, `CommandNotStarted`, `CommandResponse`). + +- **Assuming a parsed `CommandResponse` is always success.** → `CommandResponse.parse` returns `Error (ErrorResponse …)` when the status is `>= 400` or errors are present. → Handle the `Error` branch and inspect `CommandResponseError`. + +## Routing & Spot + +- **Reading the data `Spot` only from the `Reactor`.** → When the reactor pattern uses `*` (Any) for `Zone`/`Bucket`, the value comes from the `Requestor` instead. → Resolve `Zone` and `Bucket` independently, falling back to the requestor on wildcards. + +## Raw JSON + +- **Pattern-matching directly on `FSharp.Data.JsonValue` inside payload parsers.** → Brittle and ignores the provided helpers. → Use the `RawData` active patterns (`|Item|`, `|Itemi|`, `|DataItem|`, `|DataItemRaw|`, `|Json|`). + +## Schemas & Build + +- **Moving, renaming, or deleting files under `src/schema/`.** → `FSharp.Data.JsonProvider` reads them as compile-time samples; the build fails without them. → Keep the schema sample files in place and update the provider path if you intentionally relocate one. + +- **Using `dotnet add package` to add dependencies.** → The project is managed by Paket, not the NuGet CLI. → Add packages via Paket (`paket.dependencies` / `paket.references`). + +## Legacy / Dead Code + +- **Reusing the commented-out `Transform.toInternal` / `toPublic` blocks in the event module.** → That code is disabled and not part of the public API. → Use the active `CommandResponseCreated.parse` / `deriveFromCommandResponse` functions only. diff --git a/.agents/skills/fcommand/references/examples.md b/.agents/skills/fcommand/references/examples.md new file mode 100644 index 0000000..1ad0e3b --- /dev/null +++ b/.agents/skills/fcommand/references/examples.md @@ -0,0 +1,241 @@ +# Examples + +All code for this skill lives here, ordered from simplest to a full workflow. Each example is self-contained. Names like `ServiceA`, `WebApi`, `DemoSystem`, `Worker` are neutral placeholders. + +## Basic Building Blocks + +Constructing the core primitives. + +```fsharp +open Alma.Command +open Alma.ServiceIdentification + +// Validated command name (Result — handle the error case) +let request = + match Request.create "do_work" with + | Ok r -> r + | Error e -> failwith (RequestError.format e) + +// Identity (Box) and target (BoxPattern) +let requestorBox = (Box.createFromStrings ("demo", "web-api", "common", "v1", "all", "common")).Value +let reactorPattern = (Box.createFromStrings ("demo", "worker", "common", "v1", "all", "common")).Value |> BoxPattern.ofBox + +let requestor = Requestor requestorBox +let reactor = Reactor reactorPattern + +// Validity window and auth +let ttl = TimeToLive.ofSeconds 5 +let auth = AuthenticationBearer.empty + +// Typed payload item: value + type tag +let nameItem : DataItem = DataItem.createWithType ("example", "string") +``` + +## Realistic Custom Command + +A reusable command module: private wrapper type, `create`, and DTO serialization. + +```fsharp +[] +module DoWorkCommand = + open System + open Alma.Command + open Alma.Serializer + open Feather.ErrorHandling + + let private request = Request "do_work" + + type CommandData = { + Target: DataItem + } + + type Command = private Command of Command + + [] + module Command = + let internal command (Command command) = command + + let create requestor reactor authentication ttl replyTo target = + let now = DateTime.Now + let commandId = CommandId.create () + + Command { + Schema = 1 + Id = commandId + CorrelationId = CorrelationId.fromCommandId commandId + CausationId = CausationId.fromCommandId commandId + Timestamp = now |> Serialize.dateTime + + TimeToLive = ttl + AuthenticationBearer = authentication + Request = request + + Reactor = reactor + Requestor = requestor + ReplyTo = replyTo + + MetaData = OnlyCreatedAt (CreatedAt now) + Data = Data { Target = (target, "string") |> DataItem.createWithType } + } + + // Domain -> DTO -> (later) JSON + type DataDto = { target: DataItemDto } + type CommandDto = CommandDto + + let serialize : Serialize = + fun (Command command) -> + command + |> Command.toDto + MetaDataDto.serialize + (fun data -> Ok { target = data.Target |> DataItemDto.serialize id }) +``` + +## Sending a Command (Integration) + +Serialize to JSON, send, and parse the response. + +```fsharp +open Alma.Command +open Alma.Serializer +open Feather.ErrorHandling +open Feather.ErrorHandling.Result.Operators + +asyncResult { + let command = + DoWorkCommand.Command.create requestor reactor auth ttl ReplyTo.HttpCallerConnection "example" + + // Domain -> DTO (Result) -> JSON string + let! commandDto = + command + |> DoWorkCommand.serialize + |> AsyncResult.ofResult <@> DtoError.format + + let json = commandDto |> CommandDto.serialize Serialize.toJson + + // ...transport (HTTP/Kafka) returns a serialized response string... + let! rawResponse = json |> WebApi.send |> AsyncResult.ofAsync + + // Parse the response (provide metadata + data parsers) + let! response = + rawResponse + |> CommandResponse.parse (fun _ -> GenericMetaData.ofList []) (fun _ -> None) + |> AsyncResult.ofResult <@> (sprintf "%A") + + return response +} +``` + +## Parsing an Incoming Command + +Use `Command.parse` with a `RawData`-based payload parser. + +```fsharp +open Alma.Command + +let parseData (raw: RawData) : Result, CommandParseError> = + match raw with + | RawData.Item "target" (RawData.DataItem item) -> Ok (Data item.Value) + | _ -> Error MissingData + +let parsed : Result, CommandParseError> = + incomingJson + |> Command.parse MetaData.parse parseData +``` + +## Command Handler + +Validate and dispatch an incoming command. + +```fsharp +open Alma.Command +open Feather.ErrorHandling + +type HandleError = WorkFailed of string + +let handler : CommandHandler = + CommandHandler.create + (Request "do_work") + DoWorkCommand.Command.command + (fun command -> asyncResult { + // ...do the work, produce optional response data... + return Some (Data "done") + }) + +let formatError (WorkFailed m) = m +let errorTitle (WorkFailed _) = "WorkFailed" + +// Async responses are persisted via this callback; sync (HttpCallerConnection) bypass it +let persistAsync _commandId _replyTo _response = async { return () } + +let result = + CommandHandler.handle + (fun _ -> Ok ()) // custom validation + currentReactorBox // this handler's Box + formatError + errorTitle + persistAsync + handler + incomingCommand + +match result with +| CommandStarted -> printfn "async work started" +| CommandNotStarted errors -> eprintfn "%A" errors +| CommandResponse response -> printfn "sync response: %A" response.Response +``` + +## Serialization Test (Expecto) + +Normalize volatile fields before comparing JSON. + +```fsharp +open Expecto +open Alma.Command +open Alma.Serializer + +let private normalize (json: string) = + let guid = @"[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}" + let time = @"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z" + let replace (pattern: string) (repl: string) (s: string) = + System.Text.RegularExpressions.Regex.Replace(s, pattern, repl) + json |> replace guid "uuid" |> replace time "time" + +[] +let tests = + test "serializes a do_work command" { + let json = + DoWorkCommand.Command.create requestor reactor auth ttl ReplyTo.HttpCallerConnection "example" + |> DoWorkCommand.serialize + |> Result.map (CommandDto.serialize Serialize.toJson) + + match json with + | Ok serialized -> + Expect.stringContains (normalize serialized) "\"request\":\"do_work\"" "request name present" + | Error e -> failtestf "%A" e + } +``` + +## Full Workflow: Response to Kafka Event + +Create a `CommandResponse` and derive a `CommandResponseCreated` event from it. + +```fsharp +open System +open Alma.Command +open Alma.Command.Event +open Alma.ServiceIdentification + +let response : CommandResponse = + CommandResponse.create + correlationId + causationId + DateTime.Now + (ReactorResponse reactorBox) + (Requestor requestorBox) + "http" + (StatusCode System.Net.HttpStatusCode.Created) + [] // no errors + (Some (Data "done")) + +let deriver : Box = reactorBox // service emitting the event +let event = response |> CommandResponseCreated.deriveFromCommandResponse deriver commandId +``` diff --git a/.agents/skills/fcommand/references/preferred-patterns.md b/.agents/skills/fcommand/references/preferred-patterns.md new file mode 100644 index 0000000..029621a --- /dev/null +++ b/.agents/skills/fcommand/references/preferred-patterns.md @@ -0,0 +1,60 @@ +# Preferred Patterns + +## Core Principles + +- **DTO pattern is mandatory.** Never serialize a domain type directly. Always convert `Command` → `CommandDto` (via `Command.toDto`) and only then to JSON (via `CommandDto.serialize`). The same applies to responses (`CommandResponse` → `CommandResponseDto` → JSON). +- **Domain primitives are single-case DUs.** `CommandId`, `Request`, `TimeToLive`, etc. wrap raw values. Construct them through their module (`CommandId.create`, `Request.create`, `TimeToLive.ofSeconds`) and read them with `.value`. +- **All modules use `[]`.** Always qualify: `Request.create`, `CommandId.value`, `Data.data` — never the bare functions. +- **Commands are immutable records.** Build them once at creation; transform with `Command.bindMetaData` / `Command.bindData` rather than mutating. +- **Keep your `Command` constructor private.** Wrap the generic `Command<_,_>` in a private single-case DU inside your module and expose only `create` + `serialize` (see `examples.md` → Realistic Custom Command). + +## Recommended API Usage + +- **Creating a command:** assemble a `Command<'MetaData,'CommandData>` record with `Schema = 1`, IDs from `CommandId.create`, correlation/causation via `CorrelationId.fromCommandId` / `CausationId.fromCommandId`, `Reactor` from a `BoxPattern`, `Requestor` from a `Box`, and a `ReplyTo`. See `examples.md` → Realistic Custom Command. +- **Payloads:** wrap each field in a `DataItem` using `DataItem.createWithType (value, "type")` (explicit type tag) or `DataItem.create value` (inferred). Wrap the whole payload in `Data`. +- **Serializing:** `Command.toDto serializeMetaData serializeData` returns `Result`; both serializer functions must return `Result`. Then `CommandDto.serialize Serialize.toJson`. +- **Parsing:** `Command.parse parseMetaData parseData json` returns `Result, CommandParseError>`. Provide a metadata parser and a data parser that each consume a `RawData`. +- **Hand-parsing raw payloads:** use the `RawData` active patterns (`|Item|`, `|Itemi|`, `|DataItem|`, `|DataItemRaw|`, `|Json|`) instead of reaching into `JsonValue` directly. See `examples.md` → Parsing an Incoming Command. +- **Metadata:** for the simple case use the built-in `MetaData.OnlyCreatedAt` with `MetaDataDto.serialize` / `MetaData.parse`. + +## Error Handling + +- Everything is `Result`/`AsyncResult`-based. Compose inside `result { }` / `asyncResult { }` from `Feather.ErrorHandling`. +- Map errors with the `<@>` operator (`Result.mapError`) at each boundary, e.g. turn a `DtoError` into a string with `<@> DtoError.format`. +- Convert error cases to text with the provided formatters: `DtoError.format`, `CommandHandleError.format`, `ResponseError.format`, `RequestError.format`, `MetaDataParseError.format`. +- `Request.create` rejects null/empty with `EmptyRequest` — always handle the `Result`, never assume success. + +## Composition + +- **Change metadata type:** `Command.bindMetaData (f: 'MetaData -> Result<'NewMetaData,'Error>)` rebuilds the command preserving the envelope. +- **Change data type:** `Command.bindData (f: Data<'Data> -> Result,'Error>)`. +- **Drop envelope detail:** `Command.toCommon` projects to `CommonCommandData` for validation/routing logic. +- **Match on request name:** the `Command.(|OfRequest|_|)` active pattern routes by `Request`. + +## Command Handling + +- Build a handler with `CommandHandler.create request getCommand handle`, where `handle` returns `AsyncResult option, CommandHandleError<'Error>>`. +- Dispatch with `CommandHandler.handle` (uses `defaultValidations`) or `CommandHandler.handleWith` to supply custom `Validations`. +- **`Validations`** independently toggle `TimeToLive` and `Reactor` checks (`Validation.Validate` | `Validation.Ignore`); `defaultValidations` validates both. +- **TTL validation** rejects a command whose `timestamp … timestamp + ttl` window no longer contains "now" with `408 Timeout`. +- **Reactor validation** rejects the command unless the handler's `Box` matches the command's `Reactor` `BoxPattern`. +- **Reply behavior:** when `ReplyTo` is `ReplyTo.HttpCallerConnection` the handler runs synchronously and returns a `CommandResponse`; otherwise it starts async work and returns `CommandStarted`, persisting the response through the supplied callback. +- **Spot resolution:** the data `Spot` is taken from the `Reactor`; each of `Zone` and `Bucket` independently falls back to the `Requestor`'s value when the reactor pattern holds `*` (Any). + +## Integration with Other Libraries + +- `Reactor` wraps a `BoxPattern` and `Requestor`/`ReactorResponse` wrap a `Box` from `Alma.ServiceIdentification`; build them with `BoxPattern.createFromStrings` / `Create.Box` / `Box.createFromStrings`. +- Use `Serialize.toJson` for the final JSON string and `Serialize.dateTime` for timestamps from `Alma.Serializer`. +- The prepared `CommandResponseCreated` event (namespace `Alma.Command.Event`) bridges a `CommandResponse` onto a Kafka event via `CommandResponseCreated.deriveFromCommandResponse`. See `examples.md` → Full Workflow. + +## Naming Conventions + +- A module's name matches the type it operates on (`module CommandId` operates on `CommandId`). +- DTO types and modules carry the `Dto` suffix (`CommandDto`, `DataItemDto`, `ResponseErrorDto`). +- Mark implementation-only helpers `internal` / `private`; expose a minimal public surface (`create`, `serialize`, `parse`). + +## Build & Testing Recommendations + +- Build with `./build.sh build` and run tests with `./build.sh -t tests` (Expecto). The `build.sh` wrapper restores Paket packages first. +- JSON schema samples live under `src/schema/` and are consumed by `FSharp.Data.JsonProvider` at compile time — tests rely on them being present. +- Test serialization by normalizing volatile fields (UUIDs, timestamps) before comparing against an expected JSON string. See `examples.md` → Serialization Test. diff --git a/.claude/skills b/.claude/skills new file mode 120000 index 0000000..2b7a412 --- /dev/null +++ b/.claude/skills @@ -0,0 +1 @@ +../.agents/skills \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 8327808..64d0980 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,9 @@ # Alma.Command (fcommand) +This repo ships Agent Skill for the `Alma.Command` library. Compatible agents discover it automatically; see `.agents/skills/fcommand/SKILL.md`. + +## Project Purpose + Open-source F# library (`Alma.Command` NuGet package) providing generic Command types, serialization/deserialization, command handler infrastructure, and command response handling for the Alma platform's CQRS/event-driven architecture. Used by downstream microservices to define, send, parse, and handle domain commands over Kafka. ## Tech Stack diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3c3459a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,2 @@ + +@AGENTS.md