Something went wrong. Try again.
Dynamic Consistency Boundaries (DCB) Event Sourcing in Gleam !!! factos.hexdocs.pm
eventsourcing dcb dynamic-consistency-boundaries gleam
Something went wrong. Try again.
11 kB · 352 lines
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353import course_subscriptionimport course_subscription_projection as projectionimport factosimport factos/factos_sqlightimport gleam/intimport gleam/listimport gleam/option.{type Option, None, Some}import lustreimport lustre/attributeimport lustre/effectimport lustre/element.{type Element}import lustre/element/htmlimport lustre/eventimport sqlightimport youid/uuid
pub fn component( connection: sqlight.Connection,) -> lustre.App(Nil, Model, Message) { lustre.application(fn(_) { init(connection) }, update, view)}
pub opaque type Model { Model( connection: sqlight.Connection, snapshot: projection.Snapshot, status: Option(Status), )}
type Status { Processing(String) Accepted(String) Rejected(String)}
fn init(connection: sqlight.Connection) -> #(Model, effect.Effect(Message)) { let model = Model( connection:, snapshot: projection.Snapshot(courses: [], subscriptions: []), status: None, ) #(model, load_projection(connection))}
pub opaque type Message { Dispatch(command: course_subscription.Command) CommandFinished( command: course_subscription.Command, result: Result(projection.Snapshot, String), ) ProjectionLoaded(result: Result(projection.Snapshot, sqlight.Error))}
fn update(model: Model, message: Message) -> #(Model, effect.Effect(Message)) { case message { Dispatch(command:) -> #( Model(..model, status: Some(Processing(command_label(command)))), dispatch_command(model.connection, command), ) CommandFinished(command:, result: Ok(snapshot)) -> #( Model(..model, snapshot:, status: Some(Accepted(command_label(command)))), effect.none(), ) CommandFinished(command: _, result: Error(reason)) -> #( Model(..model, status: Some(Rejected(reason))), effect.none(), ) ProjectionLoaded(result: Ok(snapshot)) -> #( Model(..model, snapshot:), effect.none(), ) ProjectionLoaded(result: Error(error)) -> #( Model( ..model, status: Some(Rejected( "Could not read projections: " <> sqlight_error(error), )), ), effect.none(), ) }}
fn load_projection(connection: sqlight.Connection) -> effect.Effect(Message) { effect.from(fn(dispatch) { projection.load(connection) |> ProjectionLoaded |> dispatch })}
fn dispatch_command( connection: sqlight.Connection, command: course_subscription.Command,) -> effect.Effect(Message) { effect.from(fn(dispatch) { let configuration = factos_sqlight.Configuration( ..factos_sqlight.configure(course_subscription.model(), connection:), subscriptions: [projection.subscription()], )
let result = case factos_sqlight.dispatch( configuration, command, decision_context: course_subscription.decision_context(command), event_id: uuid.v4_string, ) { Ok(_) -> case projection.load(connection) { Ok(snapshot) -> Ok(snapshot) Error(error) -> Error("Could not read projections: " <> sqlight_error(error)) } Error(error) -> Error(error_to_string(error)) }
dispatch(CommandFinished(command:, result:)) })}
fn view(model: Model) -> Element(Message) { let projection.Snapshot(courses:, subscriptions:) = model.snapshot
html.main([], [ html.header([], [ html.h1([], [html.text("Course subscriptions")]), html.p([], [ html.text( "Commands are decided from the event log and persisted in an in-memory SQLite database.", ), ]), ]), view_status(model.status), view_commands(), view_courses(courses), view_subscriptions(subscriptions), view_rules(), ])}
fn view_status(status: Option(Status)) -> Element(message) { case status { None -> element.fragment([]) Some(Processing(command)) -> html.output([attribute.attribute("role", "status")], [ html.strong([], [html.text("Running: ")]), html.text(command), ]) Some(Accepted(command)) -> html.output([attribute.attribute("role", "status")], [ html.strong([], [html.text("Accepted: ")]), html.text(command), ]) Some(Rejected(reason)) -> html.output([attribute.attribute("role", "alert")], [ html.strong([], [html.text("Rejected: ")]), html.text(reason), ]) }}
fn view_commands() -> Element(Message) { html.section([], [ html.h2([], [html.text("Commands")]), html.p([], [ html.text( "Run the commands in any order. Repeating them also demonstrates domain rejections.", ), ]), html.h3([], [html.text("Courses")]), html.ul([], [ view_action( "Define gleam-basics with capacity 2", course_subscription.DefineCourse("gleam-basics", 2), ), view_action( "Define otp-in-practice with capacity 1", course_subscription.DefineCourse("otp-in-practice", 1), ), view_action( "Change gleam-basics capacity to 3", course_subscription.ChangeCourseCapacity("gleam-basics", 3), ), ]), html.h3([], [html.text("Students")]), html.ul([], [ view_action( "Subscribe ada to gleam-basics", course_subscription.SubscribeStudentToCourse("ada", "gleam-basics"), ), view_action( "Subscribe grace to gleam-basics", course_subscription.SubscribeStudentToCourse("grace", "gleam-basics"), ), view_action( "Subscribe linh to gleam-basics", course_subscription.SubscribeStudentToCourse("linh", "gleam-basics"), ), view_action( "Subscribe ada to otp-in-practice", course_subscription.SubscribeStudentToCourse("ada", "otp-in-practice"), ), view_action( "Subscribe grace to otp-in-practice", course_subscription.SubscribeStudentToCourse("grace", "otp-in-practice"), ), ]), ])}
fn view_action( label: String, command: course_subscription.Command,) -> Element(Message) { html.li([], [ html.button([attribute.type_("button"), event.on_click(Dispatch(command))], [ html.text(label), ]), ])}
fn view_courses(courses: List(projection.Course)) -> Element(message) { html.section([], [ html.h2([], [html.text("Course projection")]), case courses { [] -> html.p([], [html.text("No courses have been defined.")]) courses -> html.table([], [ html.caption([], [html.text("Current course capacity and occupancy")]), html.thead([], [ html.tr([], [ html.th([attribute.scope("col")], [html.text("Course")]), html.th([attribute.scope("col")], [html.text("Capacity")]), html.th([attribute.scope("col")], [html.text("Subscribed")]), ]), ]), html.tbody([], list.map(courses, view_course)), ]) }, ])}
fn view_course(course: projection.Course) -> Element(message) { let projection.Course(id:, capacity:, subscription_count:) = course html.tr([], [ html.th([attribute.scope("row")], [html.text(id)]), html.td([], [html.text(int.to_string(capacity))]), html.td([], [html.text(int.to_string(subscription_count))]), ])}
fn view_subscriptions( subscriptions: List(projection.Subscription),) -> Element(message) { html.section([], [ html.h2([], [html.text("Subscription projection")]), case subscriptions { [] -> html.p([], [html.text("No students have subscribed.")]) subscriptions -> html.ul([], list.map(subscriptions, view_subscription)) }, ])}
fn view_subscription( subscription: projection.Subscription,) -> Element(message) { let projection.Subscription(student_id:, course_id:) = subscription html.li([], [ html.text(student_id), html.text(" is subscribed to "), html.strong([], [html.text(course_id)]), ])}
fn view_rules() -> Element(message) { html.aside([], [ html.h2([], [html.text("Domain rules")]), html.ul([], [ html.li([], [ html.text("A course must exist before students can subscribe."), ]), html.li([], [ html.text("A student cannot subscribe to the same course twice."), ]), html.li([], [html.text("A course cannot exceed its current capacity.")]), html.li([], [ html.text("A student can subscribe to at most five courses."), ]), ]), ])}
fn command_label(command: course_subscription.Command) -> String { case command { course_subscription.DefineCourse(course_id:, capacity:) -> "define " <> course_id <> " with capacity " <> int.to_string(capacity) course_subscription.ChangeCourseCapacity(course_id:, new_capacity:) -> "change " <> course_id <> " capacity to " <> int.to_string(new_capacity) course_subscription.SubscribeStudentToCourse(student_id:, course_id:) -> "subscribe " <> student_id <> " to " <> course_id }}
fn error_to_string( error: factos_sqlight.Error(course_subscription.Error, sqlight.Error),) -> String { case error { factos.DomainError(error) -> domain_error_to_string(error) factos.SubscriptionError(error) -> "Projection update failed: " <> sqlight_error(error) factos.StoreError(error) -> "Event store failed: " <> factos_sqlight.sqlight_error_to_string(error) factos.AppendConditionFailed(_) -> "The relevant event history changed while the command was being handled." factos.DecodeError(_) -> "A stored event could not be decoded." factos.InvalidSchema(factos.EventType(type_), version) -> "No decoder exists for " <> type_ <> " version " <> int.to_string(version) <> "." }}
fn domain_error_to_string(error: course_subscription.Error) -> String { case error { course_subscription.CourseAlreadyExists(course_id:) -> "Course " <> course_id <> " already exists." course_subscription.CourseDoesNotExist(course_id:) -> "Course " <> course_id <> " does not exist." course_subscription.CapacityUnchanged(capacity:) -> "The course capacity is already " <> int.to_string(capacity) <> "." course_subscription.CourseFullyBooked(course_id:) -> "Course " <> course_id <> " is fully booked." course_subscription.StudentAlreadySubscribed -> "The student is already subscribed to this course." course_subscription.StudentCourseLimitReached(limit:) -> "The student is already subscribed to the limit of " <> int.to_string(limit) <> " courses." }}
fn sqlight_error(error: sqlight.Error) -> String { let sqlight.SqlightError(message:, ..) = error message}