keiro-dsl-0.2.0.0: src/Keiro/Dsl/Skeleton.hs
{- | Starter @.keiro@ skeletons for the @new \<kind\>@ subcommand. Each skeleton
is a minimal, __valid__ spec for one node kind: it parses and passes
@validateSpec@ with zero error diagnostics (a test enumerates them), so the
skeletons double as living, guaranteed-valid notation examples.
Kinds whose validator couples to other nodes (a @publisher@ needs an @emit@; an
@emit@/@intake@ needs a @contract@; a @dispatch@ needs a @workqueue@; an
@operation@ references a @workflow@) ship the whole coupled mini-spec, so the
skeleton is self-contained and checks clean on its own.
-}
module Keiro.Dsl.Skeleton (
skeletonFor,
skeletonKinds,
) where
import Data.Text (Text)
import Data.Text qualified as T
-- | The valid @new \<kind\>@ arguments, in help/listing order.
skeletonKinds :: [Text]
skeletonKinds =
[ "aggregate"
, "process"
, "router"
, "contract"
, "intake"
, "emit"
, "publisher"
, "workqueue"
, "dispatch"
, "workflow"
, "operation"
]
{- | The minimal valid spec text for a node kind, or a 'Left' error naming the
valid kinds when the argument is unrecognised.
-}
skeletonFor :: Text -> Either Text Text
skeletonFor kind = case kind of
"aggregate" -> Right aggregateSkeleton
"process" -> Right processSkeleton
"router" -> Right routerSkeleton
"contract" -> Right contractSkeleton
"intake" -> Right intakeSkeleton
"emit" -> Right emitSkeleton
"publisher" -> Right emitSkeleton
"workqueue" -> Right workqueueSkeleton
"dispatch" -> Right workqueueSkeleton
"workflow" -> Right workflowSkeleton
"operation" -> Right workflowSkeleton
other ->
Left $
"unknown kind '" <> other <> "'. Valid kinds: " <> T.intercalate ", " skeletonKinds
aggregateSkeleton :: Text
aggregateSkeleton =
T.unlines
[ "context my-service"
, ""
, "id ThingId prefix=thing"
, ""
, "aggregate Thing"
, " regs"
, " thingId ThingId = placeholder"
, " state ThingVertex = Pending"
, " states Pending Done!"
, ""
, " command DoThing { thingId attempt:Int }"
, " event ThingCompleted { thingId attempt:Int }"
, ""
, " Pending -- DoThing -->"
, " write state := Done"
, " emit ThingCompleted"
, " goto Done"
, ""
, " wire kind=ctorName fields=camelCase schemaVersion=1"
]
processSkeleton :: Text
processSkeleton =
T.unlines
[ "context my-service"
, ""
, "id HospitalId prefix=hosp"
, "id CommandId prefix=cmd"
, ""
, "process HospitalSurge"
, " name \"hospital-surge\""
, " input SurgeInput { hospitalId availableIcuBeds:Int redDemand:Int observedAt:Time }"
, " correlate input.hospitalId via idText"
, " saga Surge category \"hospitalSurge\""
, " target Hospital"
, " projections [ ]"
, ""
, " on SurgeInput"
, " advance NoteSurgeThreshold { hospitalId availableIcuBeds redDemand timerId=timer.id }"
, " dispatch Hospital@input.hospitalId ActivateSurge { hospitalId }"
, " on-appended AckOk ; on-duplicate AckOk ; on-failed Retry"
, " schedule surgeFollowUp"
, ""
, " dispatch-id strategy=uuidv5 from=(name, correlationId, sourceEventId, emitIndex)"
, " rejected => halt"
, " poison => halt"
, ""
, " timer surgeFollowUp"
, " id uuidv5 \"hospital-surge-timer:\" <> correlationId"
, " fireAt input.observedAt + 5m"
, " payload { kind=\"hospital-surge-follow-up\" hospitalId }"
, " fire dispatch Surge@correlationId MarkSurgeTimerFired { hospitalId timerId }"
, " fired-event-id uuidv5 \"hospital-surge-fired:\" <> correlationId"
, " on-ok Fired ; on-reject Fired ; on-ambiguous Retry ; on-error Retry ; not-mine Retry"
, " decode unknown-status => Cancelled"
, " max-attempts 5 dead-letter \"surge timer exceeded ceiling\""
, ""
, "aggregate Surge"
, " regs"
, " states Idle Fired!"
, ""
, " command NoteSurgeThreshold { hospitalId availableIcuBeds:Int redDemand:Int timerId }"
, " command MarkSurgeTimerFired { hospitalId timerId }"
, " event SurgeThresholdNoted = fields(NoteSurgeThreshold)"
, " event SurgeTimerMarked = fields(MarkSurgeTimerFired)"
, " Idle -- NoteSurgeThreshold --> emit SurgeThresholdNoted ; goto Idle"
, " Idle -- MarkSurgeTimerFired --> emit SurgeTimerMarked ; goto Fired"
, ""
, "aggregate Hospital"
, " regs"
, " states Operational Surging!"
, ""
, " command ActivateSurge { hospitalId }"
, " event SurgeActivated = fields(ActivateSurge)"
, " Operational -- ActivateSurge --> emit SurgeActivated ; goto Surging"
]
routerSkeleton :: Text
routerSkeleton =
T.unlines
[ "context my-service"
, ""
, "router PagingRouter"
, " name \"paging-router\""
, " input IncidentRaised { incidentId service }"
, " key input.incidentId via idText"
, " resolve stable via hole row { responderId }"
, " target Page"
, " projections [ ]"
, " dispatch-each SendPage { incidentId=input.incidentId responderId=resolved.responderId }"
, " on-appended AckOk ; on-duplicate AckOk ; on-failed Retry"
, " dispatch-id strategy=uuidv5 from=(name, key, sourceEventId, targetStreamName, occurrence)"
, " rejected => halt"
, " poison => halt"
, ""
, "aggregate Page"
, " regs"
, " states Pending Delivered!"
, ""
, " command SendPage { incidentId responderId }"
, " event PageSent = fields(SendPage)"
, ""
, " Pending -- SendPage --> emit PageSent ; goto Delivered"
]
contractSkeleton :: Text
contractSkeleton =
T.unlines
[ "context my-service"
, ""
, "contract myContract {"
, " schemaVersion 1"
, " discriminator messageType"
, ""
, " topic events \"my-service.events\""
, ""
, " event ThingHappened on events {"
, " thingId: typeid \"thing\""
, " detail: text"
, " }"
, "}"
]
intakeSkeleton :: Text
intakeSkeleton =
T.unlines
[ "context my-service"
, ""
, "contract myContract {"
, " schemaVersion 1"
, " discriminator messageType"
, " topic events \"my-service.events\""
, " event ThingHappened on events {"
, " thingId: typeid \"thing\""
, " }"
, "}"
, ""
, "intake thingInbox {"
, " contract myContract"
, " topic events"
, " accept ThingHappened"
, ""
, " bind messageId from header \"keiro-message-id\" required cross-check body"
, ""
, " dedupe key messageId policy PreferIntegrationMessageId"
, ""
, " decode {"
, " envelope strict-required lenient-optional"
, " body strict schemaVersion == 1"
, " }"
, ""
, " disposition {"
, " processed => ackOk"
, " duplicate => ackOk"
, " inProgress => retry 5s"
, " previouslyFailed => deadLetter \"previous inbox failure\""
, " decodeFailed => deadLetter"
, " dedupeFailed => deadLetter"
, " storeFailed => retry 5s"
, " }"
, "}"
]
emitSkeleton :: Text
emitSkeleton =
T.unlines
[ "context my-service"
, ""
, "contract myContract {"
, " schemaVersion 1"
, " discriminator messageType"
, " topic events \"my-service.events\""
, " event ThingAccepted on events {"
, " thingId: typeid \"thing\""
, " }"
, "}"
, ""
, "emit thingResponse {"
, " contract myContract"
, " topic events"
, " source \"my-service\""
, " key thingId"
, " map status {"
, " \"accepted\" => ThingAccepted"
, " _ => skip"
, " }"
, " messageId derive \"msg\" hole"
, " idempotencyKey derive hole"
, "}"
, ""
, "publisher thingPublisher {"
, " emit thingResponse"
, " ordering PerKeyHeadOfLine"
, " maxAttempts 10"
, " backoff constant 2s"
, " outboxId stable from messageId"
, "}"
]
workqueueSkeleton :: Text
workqueueSkeleton =
T.unlines
[ "context my-service"
, ""
, "readmodel accepted_transfer_needs {"
, " table = \"accepted_transfer_needs\""
, " schema = \"my_service\""
, " columns {"
, " reservation_id text required"
, " hospital_id text required"
, " }"
, " version = 1"
, " shape = \"fnv1a:fec517dae7760b8a\""
, " consistency = Eventual"
, " feed = subscription"
, "}"
, ""
, "readmodel transfer_decisions {"
, " table = \"transfer_decisions\""
, " schema = \"my_service\""
, " columns {"
, " reservation_id text required"
, " }"
, " version = 1"
, " shape = \"fnv1a:d44d218822582783\""
, " consistency = Eventual"
, " feed = subscription"
, "}"
, ""
, "workqueue reservation_work {"
, " queue logical = \"my_service.reservation_work\""
, " derive physical = \"my_service_reservation_work\""
, " dlq = \"my_service_reservation_work_dlq\""
, " table = \"pgmq.q_my_service_reservation_work\""
, ""
, " payload ReservationWorkItem {"
, " reservationId -> \"reservation_id\" text required"
, " hospitalId -> \"hospital_id\" text required"
, " }"
, ""
, " retry maxRetries = 3 delay = 5s dlq = on"
, ""
, " disposition {"
, " storeFailure -> retry 5s"
, " commandRejected -> deadLetter"
, " decodeFailure -> deadLetter"
, " onCodecReject -> deadLetter"
, " }"
, "}"
, ""
, "dispatch reservation_work_dispatch {"
, " source readModel = accepted_transfer_needs key = reservationId"
, " fanout body = resolveTransferCandidates"
, " dedup key = reservationId"
, " seenIn readModel = transfer_decisions field = reservation_id"
, " seenIn queue = reservation_work field = reservation_id"
, " enqueue to = reservation_work"
, "}"
]
workflowSkeleton :: Text
workflowSkeleton =
T.unlines
[ "context my-service"
, ""
, "workflow HospitalTransferReservation"
, " name \"hospital-transfer-reservation\""
, " in ReservationWorkflowInput { reservationId:Id hospitalId:Id }"
, " out ReservationWorkflowSummary"
, " id from input.reservationId via idText"
, " body"
, " step create-transfer-hold -> ReservationHold"
, " await reservation-confirmation -> ReservationConfirmation"
, " step summarize-reservation -> ReservationWorkflowSummary"
, ""
, "operation SignalReservationConfirmation"
, " signal reservation-confirmation of HospitalTransferReservation"
, " key from reservationId via reservationWorkflowId"
, " value ReservationConfirmation"
, ""
, "operation RunReservationWorkflow"
, " run HospitalTransferReservation"
, " input ReservationWorkflowInput"
, " outcome -> ReservationWorkflowRun"
]