packages feed

keiki-0.2.0.0: test/Keiki/Fixtures/UserRegistration.hs

{-# LANGUAGE BlockArguments #-}
{-# LANGUAGE QualifiedDo #-}
{-# LANGUAGE TemplateHaskell #-}

-- | The User Registration aggregate from synthesis §4, transcribed in
-- the v1 'Keiki.Core' DSL. This module is the smoke test for the
-- master plan's "the synthesis holds" gate.
--
-- See:
--
--   * @docs/research/synthesis-c-foundation-b-presentation-with-worked-examples.md@
--     §4 — the original walkthrough.
--   * @docs/research/dsl-shape-for-symbolic-register.md@ — the DSL note's
--     "Worked example" section, which this module reproduces.
--
-- v1 deviations from synthesis §4:
--
--   * 'AccountConfirmedData' carries the @confirmCode@ field (synthesis
--     §4 fix-1). This is the "fixed" schema. The "unfixed" schema —
--     'Jitsurei.UserRegistrationV0' — is a separate module that
--     drops the field for the M7 hidden-input demonstration.
--   * 'ResendConfirmationData' carries a @code@ field (the new
--     confirmation code). Per the EP-3 effects-boundary note, fresh
--     codes are generated by the runtime adapter, not pulled from
--     'IO' inside the transducer.
--   * Each output is built with 'OPack' plus a v1 hand-written
--     inverse. The DSL note's 'OPack' was extended in M4 to carry that
--     inverse; see EP-4's Decision Log.
module Keiki.Fixtures.UserRegistration
  ( -- * Domain types
    Email,
    ConfirmationCode,

    -- * Command payloads
    StartRegistrationData (..),
    ConfirmAccountData (..),
    ResendConfirmationData (..),
    FulfillGDPRRequestData (..),
    UserCmd (..),

    -- * Event payloads
    RegistrationStartedData (..),
    ConfirmationEmailSentData (..),
    AccountConfirmedData (..),
    ConfirmationResentData (..),
    AccountDeletedData (..),
    UserEvent (..),

    -- * Register file and control vertices
    UserRegRegs,
    Vertex (..),

    -- * The transducer
    userReg,
    userRegAST,
    emptyRegs,

    -- * Multi-event driver configuration (EP-20 M4)

    -- * Wire constructors (exported for testing)
    wireRegistrationStarted,
    wireConfirmationEmailSent,
    wireAccountConfirmed,
    wireConfirmationResent,
    wireAccountDeleted,

    -- * Input constructors (exported for testing and reuse)
    inCtorStart,
    inCtorConfirm,
    inCtorResend,
    inCtorGdpr,
    inpStart,
    inpConfirm,
    inpResend,
    inpGdpr,

    -- * B-presentation views (TH-derived; see EP-13 / MP-5)
    SUserVertex (..),
    UserView (..),
    userView,
  )
where

import Data.Text (Text)
import Data.Time (UTCTime)
import GHC.Generics (Generic)
import Keiki.Builder ((.=))
import Keiki.Builder qualified as B
import Keiki.Core
import Keiki.Generics (emptyRegFile)
import Keiki.Generics.TH (deriveAggregateCtors, deriveView, deriveWireCtors)
import Keiki.Symbolic (KnownInCtors (..), SomeInCtor (..))

-- * Domain types ------------------------------------------------------------

type Email = Text

type ConfirmationCode = Text

-- * Command payloads --------------------------------------------------------

data StartRegistrationData = StartRegistrationData
  { email :: Email,
    confirmCode :: ConfirmationCode,
    at :: UTCTime
  }
  deriving (Eq, Show, Generic)

data ConfirmAccountData = ConfirmAccountData
  { confirmCode :: ConfirmationCode,
    at :: UTCTime
  }
  deriving (Eq, Show, Generic)

-- | Per the effects-boundary note: fresh confirmation codes are
-- generated by the runtime adapter and arrive in the command. The
-- pure layer never pulls randomness.
data ResendConfirmationData = ResendConfirmationData
  { code :: ConfirmationCode,
    at :: UTCTime
  }
  deriving (Eq, Show, Generic)

newtype FulfillGDPRRequestData = FulfillGDPRRequestData {at :: UTCTime}
  deriving (Eq, Show, Generic)

data UserCmd
  = StartRegistration StartRegistrationData
  | ConfirmAccount ConfirmAccountData
  | ResendConfirmation ResendConfirmationData
  | FulfillGDPRRequest FulfillGDPRRequestData
  deriving (Eq, Show, Generic)

-- * Event payloads ----------------------------------------------------------

data RegistrationStartedData = RegistrationStartedData
  { email :: Email,
    confirmCode :: ConfirmationCode,
    at :: UTCTime
  }
  deriving (Eq, Show, Generic)

newtype ConfirmationEmailSentData = ConfirmationEmailSentData {email :: Email}
  deriving (Eq, Show, Generic)

-- | Synthesis §4 fix-1 schema: includes the @confirmCode@ field.
data AccountConfirmedData = AccountConfirmedData
  { email :: Email,
    confirmCode :: ConfirmationCode,
    at :: UTCTime
  }
  deriving (Eq, Show, Generic)

data ConfirmationResentData = ConfirmationResentData
  { email :: Email,
    confirmCode :: ConfirmationCode,
    at :: UTCTime
  }
  deriving (Eq, Show, Generic)

data AccountDeletedData = AccountDeletedData
  { email :: Email,
    at :: UTCTime
  }
  deriving (Eq, Show, Generic)

data UserEvent
  = RegistrationStarted RegistrationStartedData
  | ConfirmationEmailSent ConfirmationEmailSentData
  | AccountConfirmed AccountConfirmedData
  | ConfirmationResent ConfirmationResentData
  | AccountDeleted AccountDeletedData
  deriving (Eq, Show, Generic)

-- * Register file and control vertices -------------------------------------

type UserRegRegs =
  '[ '("email", Email),
     '("confirmCode", ConfirmationCode),
     '("registeredAt", UTCTime),
     '("confirmedAt", UTCTime),
     '("deletedAt", UTCTime)
   ]

data Vertex
  = PotentialCustomer
  | RequiresConfirmation
  | Confirmed
  | Deleted
  deriving (Eq, Ord, Show, Enum, Bounded)

-- | Initial register file. Each slot is pre-bound to a deferred
-- @"uninit: <slot>"@ error by 'Keiki.Generics.emptyRegFile' so reads
-- of an uninitialized slot crash with a targeted message instead of
-- a silent bottom.
emptyRegs :: RegFile UserRegRegs
emptyRegs = emptyRegFile

-- * Per-constructor input projections + guards (TH-derived) --------------

--
-- One 'InCtor' value per command constructor describes how to match
-- and re-build the constructor's payload as a typed 'RegFile'. The
-- per-constructor 'inpFoo' helpers turn an 'Index' (typically written
-- via 'OverloadedLabels' as @inpStart #email@) into a 'Term' that
-- structurally projects the named field of the named command
-- constructor. The matching @isFoo@ guards reduce to
-- @PInCtor@ atoms that the SBV-backed 'BoolAlg' instance recognizes
-- symbolically. 'solveOutput' walks the 'OutFields' bound by an
-- edge's @output@ and reconstructs the input @ci@ mechanically; no
-- per-edge inverse function is needed.
--
-- All declarations in this section are emitted by the splice below;
-- the slot-list types come from 'Keiki.Generics.RegFieldsOf' applied
-- to each command payload's 'Generic' representation. Singleton
-- 'Continue' uses 'mkInCtor0' under the hood (no @inp@ projection
-- because @'Index' \'[]@ is uninhabited).

$( deriveAggregateCtors
     ''UserCmd
     ''UserRegRegs
     [ ("StartRegistration", "Start"),
       ("ConfirmAccount", "Confirm"),
       ("ResendConfirmation", "Resend"),
       ("FulfillGDPRRequest", "Gdpr")
     ]
 )

-- | Enumerate the five 'InCtor' values of 'UserCmd' so 'symSatExt'
-- can rebuild a concrete 'UserCmd' from an SBV model: the model's
-- @"inputCtor"@ string is matched against these entries' 'icName',
-- and the matching entry's @icBuild@ is called over a 'RegFile'
-- assembled from the @"inp/<icName>/<slot>"@ model values.
instance KnownInCtors UserCmd where
  allInCtors =
    [ SomeInCtor inCtorStart,
      SomeInCtor inCtorConfirm,
      SomeInCtor inCtorResend,
      SomeInCtor inCtorGdpr
    ]

-- * Wire constructors for events (TH-derived) ----------------------------

--
-- One 'WireCtor' value per event constructor. The nested-pair field
-- tuple comes from each event record's 'Generic' instance via
-- 'Keiki.Generics.FieldsOf'.

$( deriveWireCtors
     ''UserEvent
     [ ("RegistrationStarted", "RegistrationStarted"),
       ("ConfirmationEmailSent", "ConfirmationEmailSent"),
       ("AccountConfirmed", "AccountConfirmed"),
       ("ConfirmationResent", "ConfirmationResent"),
       ("AccountDeleted", "AccountDeleted")
     ]
 )

-- * B-presentation views (TH-derived) ------------------------------------

--
-- The B-view is a per-vertex projection on top of the shared
-- 'RegFile UserRegRegs'. For each control vertex 'v' the splice
-- generates a 'UserView' constructor that exposes only the slots the
-- vertex actually uses (the "live" slots). Pattern-matching on
-- @userView SConfirmed regs@ yields a @ConfirmedV { cEmail = …,
-- cConfirmedAt = … }@ value whose record selectors are the live
-- slots only — the type system blocks the reader from asking
-- @SPotentialCustomer@ for @cConfirmedAt@.
--
-- The projection is opt-in and downstream of the transducer: nothing
-- in 'userReg' below references 'userView'. See
-- @docs/research/genview-th-splice-design.md@ and the synthesis
-- note's §3 for the design rationale.

$( deriveView
     ''Vertex
     ''UserRegRegs
     "SUserVertex"
     "UserView"
     "userView"
     [ ("PotentialCustomer", []),
       ("RequiresConfirmation", ["email", "confirmCode"]),
       ("Confirmed", ["email", "confirmedAt"]),
       ("Deleted", ["email", "deletedAt"])
     ]
 )

-- * The transducer ---------------------------------------------------------

-- | The aggregate's transducer, authored with 'Keiki.Builder'. This
-- is the canonical form every downstream consumer
-- ('Keiki.Composition.compose', the deciders, the symbolic
-- analyses, the example specs) uses by name.
userReg ::
  SymTransducer
    (HsPred UserRegRegs UserCmd)
    UserRegRegs
    Vertex
    UserCmd
    UserEvent
userReg = B.buildTransducer
  PotentialCustomer
  emptyRegs
  (\case Deleted -> True; _ -> False)
  do
    B.from PotentialCustomer do
      -- EP-19 M7: collapsed entrance — two emits in one transition.
      B.onCmd inCtorStart $ \d -> B.do
        B.slot @"email" .= d.email
        B.slot @"confirmCode" .= d.confirmCode
        B.slot @"registeredAt" .= d.at
        B.emit
          wireRegistrationStarted
          RegistrationStartedTermFields
            { email = d.email,
              confirmCode = d.confirmCode,
              at = d.at
            }
        B.emit
          wireConfirmationEmailSent
          ConfirmationEmailSentTermFields {email = d.email}
        B.goto RequiresConfirmation

    B.from RequiresConfirmation do
      -- Right code: confirm. The InCtor-match guard from `onCmd`
      -- short-circuits the d.confirmCode read for non-ConfirmAccount
      -- inputs.
      B.onCmd inCtorConfirm $ \d -> B.do
        B.requireEq d.confirmCode #confirmCode
        B.slot @"confirmedAt" .= d.at
        B.emit
          wireAccountConfirmed
          AccountConfirmedTermFields
            { email = #email,
              confirmCode = d.confirmCode,
              at = d.at
            }
        B.goto Confirmed

      -- Resend: rotate the code (code arrives in the command).
      B.onCmd inCtorResend $ \d -> B.do
        B.slot @"confirmCode" .= d.code
        B.slot @"registeredAt" .= d.at
        B.emit
          wireConfirmationResent
          ConfirmationResentTermFields
            { email = #email,
              confirmCode = d.code,
              at = d.at
            }
        B.goto RequiresConfirmation

      -- GDPR before confirmation is durable just like post-confirmation
      -- deletion: changing state without an event would be lost on replay.
      B.onCmd inCtorGdpr $ \d -> B.do
        B.slot @"deletedAt" .= d.at
        B.emit
          wireAccountDeleted
          AccountDeletedTermFields
            { email = #email,
              at = d.at
            }
        B.goto Deleted

    B.from Confirmed do
      B.onCmd inCtorGdpr $ \d -> B.do
        B.slot @"deletedAt" .= d.at
        B.emit
          wireAccountDeleted
          AccountDeletedTermFields
            { email = #email,
              at = d.at
            }
        B.goto Deleted

-- Deleted is terminal; defaults to [] without an explicit `from`.

-- * AST form (legacy, retained for the M5 equivalence test) ----------------

-- | The same transducer hand-authored against the post-MP-6
-- "Keiki.Core" AST. Retained as a side-by-side reference for the
-- 'Jitsurei.UserRegistrationBuilderSpec' equivalence test;
-- removable in a follow-up plan once the migration is judged
-- stable.
userRegAST ::
  SymTransducer
    (HsPred UserRegRegs UserCmd)
    UserRegRegs
    Vertex
    UserCmd
    UserEvent
userRegAST =
  SymTransducer
    { edgesOut = userRegASTEdges,
      initial = PotentialCustomer,
      initialRegs = emptyRegs,
      isFinal = \case Deleted -> True; _ -> False
    }

userRegASTEdges ::
  Vertex ->
  [Edge (HsPred UserRegRegs UserCmd) UserRegRegs UserCmd UserEvent Vertex]
userRegASTEdges = \case
  -- EP-19 M7: collapsed to one length-2 multi-event edge.
  PotentialCustomer ->
    [ Edge
        { guard = isStart,
          update =
            USet (#email :: IndexN "email" UserRegRegs Email) (inpStart #email)
              `combine` USet
                (#confirmCode :: IndexN "confirmCode" UserRegRegs ConfirmationCode)
                (inpStart #confirmCode)
              `combine` USet
                (#registeredAt :: IndexN "registeredAt" UserRegRegs UTCTime)
                (inpStart #at),
          output =
            [ pack
                inCtorStart
                wireRegistrationStarted
                ( OFCons
                    (inpStart #email)
                    ( OFCons
                        (inpStart #confirmCode)
                        (OFCons (inpStart #at) OFNil)
                    )
                ),
              pack
                inCtorStart
                wireConfirmationEmailSent
                (OFCons (inpStart #email) OFNil)
            ],
          target = RequiresConfirmation
        }
    ]
  RequiresConfirmation ->
    [ -- Right code: confirm. The 'isConfirm' conjunct short-circuits the
      -- inpConfirm read for non-ConfirmAccount inputs.
      Edge
        { guard =
            PAnd
              isConfirm
              ( inpConfirm #confirmCode
                  .== proj (#confirmCode :: Index UserRegRegs ConfirmationCode)
              ),
          update =
            USet
              (#confirmedAt :: IndexN "confirmedAt" UserRegRegs UTCTime)
              (inpConfirm #at),
          output =
            [ pack
                inCtorConfirm
                wireAccountConfirmed
                ( OFCons
                    (proj (#email :: Index UserRegRegs Email))
                    ( OFCons
                        (inpConfirm #confirmCode)
                        (OFCons (inpConfirm #at) OFNil)
                    )
                )
            ],
          target = Confirmed
        },
      -- Resend: rotate the code (code arrives in the command).
      Edge
        { guard = isResend,
          update =
            USet
              (#confirmCode :: IndexN "confirmCode" UserRegRegs ConfirmationCode)
              (inpResend #code)
              `combine` USet
                (#registeredAt :: IndexN "registeredAt" UserRegRegs UTCTime)
                (inpResend #at),
          output =
            [ pack
                inCtorResend
                wireConfirmationResent
                ( OFCons
                    (proj (#email :: Index UserRegRegs Email))
                    ( OFCons
                        (inpResend #code)
                        (OFCons (inpResend #at) OFNil)
                    )
                )
            ],
          target = RequiresConfirmation
        },
      -- GDPR before confirmation emits the deletion event so replay observes
      -- the state and register change.
      Edge
        { guard = isGdpr,
          update =
            USet
              (#deletedAt :: IndexN "deletedAt" UserRegRegs UTCTime)
              (inpGdpr #at),
          output =
            [ pack
                inCtorGdpr
                wireAccountDeleted
                ( OFCons
                    (proj (#email :: Index UserRegRegs Email))
                    (OFCons (inpGdpr #at) OFNil)
                )
            ],
          target = Deleted
        }
    ]
  Confirmed ->
    [ Edge
        { guard = isGdpr,
          update =
            USet
              (#deletedAt :: IndexN "deletedAt" UserRegRegs UTCTime)
              (inpGdpr #at),
          output =
            [ pack
                inCtorGdpr
                wireAccountDeleted
                ( OFCons
                    (proj (#email :: Index UserRegRegs Email))
                    (OFCons (inpGdpr #at) OFNil)
                )
            ],
          target = Deleted
        }
    ]
  Deleted -> []