Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions .agents/skills/fcommand/SKILL.md
Original file line number Diff line number Diff line change
@@ -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<string,string>` 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`.
45 changes: 45 additions & 0 deletions .agents/skills/fcommand/references/anti-patterns.md
Original file line number Diff line number Diff line change
@@ -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.
241 changes: 241 additions & 0 deletions .agents/skills/fcommand/references/examples.md
Original file line number Diff line number Diff line change
@@ -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<string> = DataItem.createWithType ("example", "string")
```

## Realistic Custom Command

A reusable command module: private wrapper type, `create`, and DTO serialization.

```fsharp
[<RequireQualifiedAccess>]
module DoWorkCommand =
open System
open Alma.Command
open Alma.Serializer
open Feather.ErrorHandling

let private request = Request "do_work"

type CommandData = {
Target: DataItem<string>
}

type Command = private Command of Command<MetaData, CommandData>

[<RequireQualifiedAccess>]
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<string> }
type CommandDto = CommandDto<MetaDataDto.OnlyCreatedAt, DataDto>

let serialize : Serialize<Command, MetaData, CommandData, CommandDto> =
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<Data<string>, CommandParseError> =
match raw with
| RawData.Item "target" (RawData.DataItem item) -> Ok (Data item.Value)
| _ -> Error MissingData

let parsed : Result<Command<MetaData, string>, 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<DoWorkCommand.Command, MetaData, DoWorkCommand.CommandData, string, HandleError> =
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"

[<Tests>]
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<GenericMetaData, string> =
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
```
Loading