diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..da18e29 --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +github:tylerbutler/trellis 0.10.3 diff --git a/README.md b/README.md index 837c402..a9ef11c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Factos +# 🤓☝️ Factos Factos provides primitives and storage backends for building event-sourced systems in Gleam. diff --git a/backends/factos_pog/dev/factos_pog_dev.gleam b/backends/factos_pog/dev/factos_pog_dev.gleam index cc12cda..b616153 100644 --- a/backends/factos_pog/dev/factos_pog_dev.gleam +++ b/backends/factos_pog/dev/factos_pog_dev.gleam @@ -421,7 +421,6 @@ fn codec() -> factos_pog.EventCodec(Event) { fn encode(event: Event) -> factos_pog.Proposed(Event) { let Incremented(stream_name: _, value:) = event factos_pog.new_proposed( - event:, type_: factos.event_type("Incremented"), version: 1, data: bit_array.from_string(int.to_string(value)), diff --git a/backends/factos_pog/src/factos/factos_pog.gleam b/backends/factos_pog/src/factos/factos_pog.gleam index 1cef81b..8223a9f 100644 --- a/backends/factos_pog/src/factos/factos_pog.gleam +++ b/backends/factos_pog/src/factos/factos_pog.gleam @@ -36,7 +36,6 @@ import pog /// through the builder functions. pub opaque type Proposed(event) { Proposed( - event: event, type_: factos.EventType, version: Int, tags: List(factos.Tag), @@ -47,19 +46,11 @@ pub opaque type Proposed(event) { /// Prepare a domain event with empty tags and metadata for persistence. pub fn new_proposed( - event event: event, type_ type_: factos.EventType, version version: Int, data data: BitArray, ) -> Proposed(event) { - Proposed( - event:, - type_:, - version:, - tags: [], - metadata: factos.empty_metadata(), - data:, - ) + Proposed(type_:, version:, tags: [], metadata: factos.empty_metadata(), data:) } /// Replace the tags on a proposed event. @@ -1087,8 +1078,7 @@ fn insert_events( } [event, ..rest] -> { let EventCodec(encode:, ..) = codec - let Proposed(type_:, version:, tags:, metadata:, data:, ..) = - encode(event) + let Proposed(type_:, version:, tags:, metadata:, data:) = encode(event) let id = event_id() use returned_position <- result.try(insert_event_if_revision_matches( connection, diff --git a/backends/factos_pog/test/factos_pog_test.gleam b/backends/factos_pog/test/factos_pog_test.gleam index f0c40ee..d504c01 100644 --- a/backends/factos_pog/test/factos_pog_test.gleam +++ b/backends/factos_pog/test/factos_pog_test.gleam @@ -2336,7 +2336,6 @@ fn encode(event: Event) -> factos_pog.Proposed(Event) { fn proposed_event(event: Event) -> factos_pog.Proposed(Event) { factos_pog.new_proposed( - event:, type_: factos.event_type("UserRegistered"), version: 1, data: bit_array.from_string(event.username), @@ -2498,7 +2497,6 @@ fn encode_counter_event( case event { Incremented(value) -> factos_pog.new_proposed( - event:, type_: factos.event_type("Incremented"), version: 1, data: bit_array.from_string(int.to_string(value)), diff --git a/examples/course_subscriptions/.gitignore b/examples/course_subscriptions/.gitignore new file mode 100644 index 0000000..982745b --- /dev/null +++ b/examples/course_subscriptions/.gitignore @@ -0,0 +1,7 @@ +*.beam +*.ez +/build +**/build +node_modules +**/node_modules +erl_crash.dump diff --git a/examples/course_subscriptions/dev/course_subscriptions_dev.gleam b/examples/course_subscriptions/dev/course_subscriptions_dev.gleam new file mode 100644 index 0000000..5a34d31 --- /dev/null +++ b/examples/course_subscriptions/dev/course_subscriptions_dev.gleam @@ -0,0 +1,458 @@ +import course_subscription +import factos +import factos/factos_pog +import gleam/erlang/application +import gleam/erlang/process +import gleam/io +import gleam/list +import gleam/option +import gleam/otp/actor +import gleam/string +import pog +import simplifile +import testcontainer +import testcontainer/error as testcontainer_error +import testcontainer_formulas/postgres +import youid/uuid + +pub type ExampleResult { + ExampleResult( + defined_courses: Int, + capacity_changes: Int, + accepted_subscriptions: Int, + rejected_constraints: Int, + concurrent_acceptances: Int, + concurrent_rejections: Int, + stored_events: Int, + ) +} + +type WorkerMessage { + WorkerReady(worker: String, release: process.Subject(Nil)) + WorkerFinished( + worker: String, + result: Result( + factos_pog.Dispatch(course_subscription.Event), + factos_pog.Error(course_subscription.Error), + ), + ) +} + +pub fn run() -> Result(ExampleResult, testcontainer_error.Error) { + use postgres_container <- testcontainer.with_formula( + postgres.new() |> postgres.formula(), + ) + let #(pool_pid, connection) = start_connection(postgres_container) + install_event_store(connection) + let dispatcher = configured_dispatcher() + + let assert Ok(_) = + course_subscription.dispatch( + connection, + dispatcher, + course_subscription.DefineCourse(course_id: "c1", capacity: 2), + uuid.v4_string, + ) + let assert Error(factos_pog.DomainError(course_subscription.CourseAlreadyExists( + course_id: "c1", + ))) = + course_subscription.dispatch( + connection, + dispatcher, + course_subscription.DefineCourse(course_id: "c1", capacity: 15), + uuid.v4_string, + ) + let assert Error(factos_pog.DomainError(course_subscription.CourseDoesNotExist( + course_id: "c0", + ))) = + course_subscription.dispatch( + connection, + dispatcher, + course_subscription.ChangeCourseCapacity( + course_id: "c0", + new_capacity: 15, + ), + uuid.v4_string, + ) + let assert Error(factos_pog.DomainError(course_subscription.CapacityUnchanged( + capacity: 2, + ))) = + course_subscription.dispatch( + connection, + dispatcher, + course_subscription.ChangeCourseCapacity(course_id: "c1", new_capacity: 2), + uuid.v4_string, + ) + let assert Ok(_) = + course_subscription.dispatch( + connection, + dispatcher, + course_subscription.ChangeCourseCapacity(course_id: "c1", new_capacity: 3), + uuid.v4_string, + ) + + let assert Error(factos_pog.DomainError(course_subscription.CourseDoesNotExist( + course_id: "missing", + ))) = + course_subscription.dispatch( + connection, + dispatcher, + course_subscription.SubscribeStudentToCourse( + student_id: "s1", + course_id: "missing", + ), + uuid.v4_string, + ) + require_command( + connection, + dispatcher, + course_subscription.SubscribeStudentToCourse( + student_id: "s1", + course_id: "c1", + ), + ) + let assert Error(factos_pog.DomainError( + course_subscription.StudentAlreadySubscribed, + )) = + course_subscription.dispatch( + connection, + dispatcher, + course_subscription.SubscribeStudentToCourse( + student_id: "s1", + course_id: "c1", + ), + uuid.v4_string, + ) + require_command( + connection, + dispatcher, + course_subscription.SubscribeStudentToCourse( + student_id: "s2", + course_id: "c1", + ), + ) + require_command( + connection, + dispatcher, + course_subscription.SubscribeStudentToCourse( + student_id: "s3", + course_id: "c1", + ), + ) + let assert Error(factos_pog.DomainError(course_subscription.CourseFullyBooked( + course_id: "c1", + ))) = + course_subscription.dispatch( + connection, + dispatcher, + course_subscription.SubscribeStudentToCourse( + student_id: "s4", + course_id: "c1", + ), + uuid.v4_string, + ) + + ["c2", "c3", "c4", "c5", "c6", "c7"] + |> list.each(fn(course_id) { + require_command( + connection, + dispatcher, + course_subscription.DefineCourse(course_id:, capacity: 10), + ) + }) + ["c2", "c3", "c4", "c5", "c6"] + |> list.each(fn(course_id) { + require_command( + connection, + dispatcher, + course_subscription.SubscribeStudentToCourse( + student_id: "limited", + course_id:, + ), + ) + }) + let assert Error(factos_pog.DomainError(course_subscription.StudentCourseLimitReached( + limit: 5, + ))) = + course_subscription.dispatch( + connection, + dispatcher, + course_subscription.SubscribeStudentToCourse( + student_id: "limited", + course_id: "c7", + ), + uuid.v4_string, + ) + + require_command( + connection, + dispatcher, + course_subscription.DefineCourse(course_id: "c8", capacity: 1), + ) + let concurrent_results = run_concurrent_final_seat(connection, dispatcher) + let concurrent_acceptances = + list.count(concurrent_results, where: is_accepted) + let concurrent_rejections = + list.count(concurrent_results, where: is_fully_booked) + assert concurrent_acceptances == 1 + assert concurrent_rejections == 1 + + let assert Ok(events) = + factos_pog.read_events_after( + connection, + query: factos.AllEvents, + after: factos.NoPosition, + limit: 100, + codec: course_subscription.codec(), + ) + assert list.count(events, where: is_course_defined) == 8 + assert list.count(events, where: is_capacity_changed) == 1 + assert list.count(events, where: is_student_subscribed) == 9 + assert count_course_subscriptions(events, "c8") == 1 + assert list.length(events) == 18 + + process.send_exit(pool_pid) + process.sleep(100) + Ok(ExampleResult( + defined_courses: 8, + capacity_changes: 1, + accepted_subscriptions: 9, + rejected_constraints: 8, + concurrent_acceptances:, + concurrent_rejections:, + stored_events: list.length(events), + )) +} + +pub fn main() -> Nil { + let assert Ok(ExampleResult( + defined_courses: 8, + capacity_changes: 1, + accepted_subscriptions: 9, + rejected_constraints: 8, + concurrent_acceptances: 1, + concurrent_rejections: 1, + stored_events: 18, + )) = run() + io.println("course definitions and capacity changes: accepted") + io.println("duplicate and missing courses: rejected") + io.println("course capacity and student limit: enforced") + io.println("duplicate subscription: rejected") + io.println("concurrent final seat: 1 accepted, 1 rejected") + io.println("persisted events: 18") +} + +fn configured_dispatcher() -> factos_pog.Dispatcher(course_subscription.Event) { + let assert Ok(dispatcher) = course_subscription.dispatcher() + dispatcher +} + +fn require_command( + connection: pog.Connection, + dispatcher: factos_pog.Dispatcher(course_subscription.Event), + command: course_subscription.Command, +) -> Nil { + let assert Ok(_) = + course_subscription.dispatch( + connection, + dispatcher, + command, + uuid.v4_string, + ) + Nil +} + +fn run_concurrent_final_seat( + connection: pog.Connection, + dispatcher: factos_pog.Dispatcher(course_subscription.Event), +) -> List( + Result( + factos_pog.Dispatch(course_subscription.Event), + factos_pog.Error(course_subscription.Error), + ), +) { + let messages = process.new_subject() + start_worker( + connection, + dispatcher:, + messages:, + worker: "first", + student_id: "s8a", + ) + start_worker( + connection, + dispatcher:, + messages:, + worker: "second", + student_id: "s8b", + ) + + let first_release = receive_worker_ready(messages) + let second_release = receive_worker_ready(messages) + process.send(first_release, Nil) + process.send(second_release, Nil) + [ + receive_worker_finished(messages), + receive_worker_finished(messages), + ] +} + +fn start_worker( + connection: pog.Connection, + dispatcher dispatcher: factos_pog.Dispatcher(course_subscription.Event), + messages messages: process.Subject(WorkerMessage), + worker worker: String, + student_id student_id: String, +) -> process.Pid { + process.spawn(fn() { + let release = process.new_subject() + process.send(messages, WorkerReady(worker:, release:)) + let assert Ok(Nil) = process.receive(release, within: 10_000) + let result = + course_subscription.dispatch( + connection, + dispatcher, + course_subscription.SubscribeStudentToCourse( + student_id:, + course_id: "c8", + ), + uuid.v4_string, + ) + process.send(messages, WorkerFinished(worker:, result:)) + }) +} + +fn receive_worker_ready( + messages: process.Subject(WorkerMessage), +) -> process.Subject(Nil) { + let assert Ok(message) = process.receive(messages, within: 10_000) + let assert WorkerReady(worker: _, release:) = message + release +} + +fn receive_worker_finished( + messages: process.Subject(WorkerMessage), +) -> Result( + factos_pog.Dispatch(course_subscription.Event), + factos_pog.Error(course_subscription.Error), +) { + let assert Ok(message) = process.receive(messages, within: 10_000) + let assert WorkerFinished(worker: _, result:) = message + result +} + +fn is_accepted( + result: Result( + factos_pog.Dispatch(course_subscription.Event), + factos_pog.Error(course_subscription.Error), + ), +) -> Bool { + case result { + Ok(_) -> True + Error(_) -> False + } +} + +fn is_fully_booked( + result: Result( + factos_pog.Dispatch(course_subscription.Event), + factos_pog.Error(course_subscription.Error), + ), +) -> Bool { + case result { + Error(factos_pog.DomainError(course_subscription.CourseFullyBooked( + course_id: "c8", + ))) -> True + Ok(_) | Error(_) -> False + } +} + +fn is_course_defined( + recorded: factos.Recorded(course_subscription.Event), +) -> Bool { + case recorded.event { + course_subscription.CourseDefined(course_id: _, capacity: _) -> True + course_subscription.CourseCapacityChanged(course_id: _, new_capacity: _) + | course_subscription.StudentSubscribedToCourse(student_id: _, course_id: _) -> + False + } +} + +fn is_capacity_changed( + recorded: factos.Recorded(course_subscription.Event), +) -> Bool { + case recorded.event { + course_subscription.CourseCapacityChanged(course_id: _, new_capacity: _) -> + True + course_subscription.CourseDefined(course_id: _, capacity: _) + | course_subscription.StudentSubscribedToCourse(student_id: _, course_id: _) -> + False + } +} + +fn is_student_subscribed( + recorded: factos.Recorded(course_subscription.Event), +) -> Bool { + case recorded.event { + course_subscription.StudentSubscribedToCourse(student_id: _, course_id: _) -> + True + course_subscription.CourseDefined(course_id: _, capacity: _) + | course_subscription.CourseCapacityChanged(course_id: _, new_capacity: _) -> + False + } +} + +fn count_course_subscriptions( + events: List(factos.Recorded(course_subscription.Event)), + course_id: String, +) -> Int { + list.count(events, where: fn(recorded) { + case recorded.event { + course_subscription.StudentSubscribedToCourse( + student_id: _, + course_id: event_course_id, + ) -> event_course_id == course_id + course_subscription.CourseDefined(course_id: _, capacity: _) + | course_subscription.CourseCapacityChanged(course_id: _, new_capacity: _) -> + False + } + }) +} + +fn start_connection( + postgres_container: postgres.PostgresContainer, +) -> #(process.Pid, pog.Connection) { + let postgres.PostgresContainer(host:, port:, database:, username:, ..) = + postgres_container + let pool_name = process.new_name("course_subscription") + let config = + pog.default_config(pool_name) + |> pog.host(host) + |> pog.port(port) + |> pog.database(database) + |> pog.user(username) + |> pog.password(option.Some("postgres")) + |> pog.ssl(pog.SslDisabled) + + let assert Ok(actor.Started(pid:, ..)) = pog.start(config) + process.sleep(100) + #(pid, pog.named_connection(pool_name)) +} + +fn install_event_store(connection: pog.Connection) -> Nil { + let assert Ok(priv_directory) = application.priv_directory("factos_pog") + let assert Ok(sql) = + simplifile.read( + priv_directory <> "/dbmate/20260703000100_factos_pog_event_store.sql", + ) + let assert [up, ..] = string.split(sql, "-- migrate:down") + + up + |> string.split(";") + |> list.map(string.trim) + |> list.filter(fn(statement) { statement != "" }) + |> list.each(fn(statement) { + let assert Ok(_) = pog.query(statement) |> pog.execute(on: connection) + Nil + }) +} diff --git a/examples/course_subscriptions/gleam.toml b/examples/course_subscriptions/gleam.toml new file mode 100644 index 0000000..901d028 --- /dev/null +++ b/examples/course_subscriptions/gleam.toml @@ -0,0 +1,18 @@ +name = "course_subscriptions" +version = "1.0.0" + +[dependencies] +factos = { path = "../.." } +factos_pog = { path = "../../backends/factos_pog" } +gleam_json = ">= 3.1.0 and < 4.0.0" +gleam_stdlib = ">= 1.0.0 and < 2.0.0" +pog = { git = "https://github.com/foxfriends/pog.git", ref = "919fd6ac96095ea11fa7c940b17eaece49cc5993" } + +[dev_dependencies] +gleam_erlang = ">= 1.0.0 and < 2.0.0" +gleam_otp = ">= 1.2.0 and < 2.0.0" +gleeunit = ">= 1.0.0 and < 2.0.0" +simplifile = ">= 2.5.0 and < 3.0.0" +testcontainer = ">= 1.0.2 and < 2.0.0" +testcontainer_formulas = ">= 1.0.0 and < 2.0.0" +youid = ">= 1.5.4 and < 2.0.0" diff --git a/examples/course_subscriptions/manifest.toml b/examples/course_subscriptions/manifest.toml new file mode 100644 index 0000000..7f06b78 --- /dev/null +++ b/examples/course_subscriptions/manifest.toml @@ -0,0 +1,46 @@ +# Do not manually edit this file, it is managed by Gleam. +# +# This file locks the dependency versions used, to make your build +# deterministic and to prevent unexpected versions from being included +# in your application. +# +# You should check this file into your source control repository. + +packages = [ + { name = "backoff", version = "1.1.6", build_tools = ["rebar3"], requirements = [], otp_app = "backoff", source = "hex", outer_checksum = "CF0CFFF8995FB20562F822E5CC47D8CCF664C5ECDC26A684CBE85C225F9D7C39" }, + { name = "cowl", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "cowl", source = "hex", outer_checksum = "7849E7C789D7228243A4253138FC883720A0BB44AEF406102328CADC64C3CA2B" }, + { name = "envie", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envie", source = "hex", outer_checksum = "E7EBA39310F32A40BF3EDDD7CD9C7A2BC289909983D357411C22873415BC322A" }, + { name = "exception", version = "2.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "6BDEA95248093599391C3B5DF1835C5C6A86C353C2F99CE539B450E3432FE117" }, + { name = "factos", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], source = "local", path = "../.." }, + { name = "factos_pog", version = "2.0.0", build_tools = ["gleam"], requirements = ["factos", "gleam_erlang", "gleam_otp", "gleam_stdlib", "gleam_time", "pog"], source = "local", path = "../../backends/factos_pog" }, + { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, + { name = "gleam_crypto", version = "1.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "2DE9E4EF53CF6FEE049D4F765731F7178F7A11AEFAE00EEE63BF7536B354AD3F" }, + { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, + { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, + { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, + { name = "gleam_stdlib", version = "1.0.5", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "CEE5B6C076A85B45F60C585F4316C63EC8B7127C119D5738C3958A9C4D50404E" }, + { name = "gleam_time", version = "1.8.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "533D8723774D61AD4998324F5DD1DABDCDBFABAFB9E87CB5D03C6955448FC97D" }, + { name = "gleeunit", version = "1.11.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "EC31ABA74256AEA531EDF8169931D775BBB384FED0A8A1BDC4DD9354E3E21826" }, + { name = "opentelemetry_api", version = "1.5.0", build_tools = ["rebar3", "mix"], requirements = [], otp_app = "opentelemetry_api", source = "hex", outer_checksum = "F53EC8A1337AE4A487D43AC89DA4BD3A3C99DDF576655D071DEED8B56A2D5DDA" }, + { name = "pg_types", version = "0.6.0", build_tools = ["rebar3"], requirements = [], otp_app = "pg_types", source = "hex", outer_checksum = "9949A4849DD13408FA249AB7B745E0D2DFDB9532AEE2B9722326E33CD082A778" }, + { name = "pgo", version = "0.20.0", build_tools = ["rebar3"], requirements = ["backoff", "opentelemetry_api", "pg_types"], otp_app = "pgo", source = "hex", outer_checksum = "2F11E6649CEB38E569EF56B16BE1D04874AE5B11A02867080A2817CE423C683B" }, + { name = "pog", version = "4.1.0", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_otp", "gleam_stdlib", "gleam_time", "pgo"], source = "git", repo = "https://github.com/foxfriends/pog.git", commit = "919fd6ac96095ea11fa7c940b17eaece49cc5993" }, + { name = "simplifile", version = "2.7.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "A2727627B063E87351934C7F7F008F2D1FDB16F6DE0B8C79F9E46459CFC9C164" }, + { name = "testcontainer", version = "1.0.2", build_tools = ["gleam"], requirements = ["cowl", "envie", "gleam_erlang", "gleam_json", "gleam_stdlib"], otp_app = "testcontainer", source = "hex", outer_checksum = "784768485ED2380AA543A0CC3F06F7A368B0DD209102C57E800E0A87E1D2FC81" }, + { name = "testcontainer_formulas", version = "1.0.0", build_tools = ["gleam"], requirements = ["cowl", "gleam_stdlib", "testcontainer"], otp_app = "testcontainer_formulas", source = "hex", outer_checksum = "F9A86A2F8400A0C72FE98F56EF5B3FD1CE10F0A63D968A1C57EA0087A3E5802B" }, + { name = "youid", version = "1.6.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_stdlib", "gleam_time"], otp_app = "youid", source = "hex", outer_checksum = "7A3ABA44B1B38BC2BDCB5474C5317AA372BE58DFBC649815EE08B03526DDA18D" }, +] + +[requirements] +factos = { path = "../.." } +factos_pog = { path = "../../backends/factos_pog" } +gleam_erlang = { version = ">= 1.0.0 and < 2.0.0" } +gleam_json = { version = ">= 3.1.0 and < 4.0.0" } +gleam_otp = { version = ">= 1.2.0 and < 2.0.0" } +gleam_stdlib = { version = ">= 1.0.0 and < 2.0.0" } +gleeunit = { version = ">= 1.0.0 and < 2.0.0" } +pog = { git = "https://github.com/foxfriends/pog.git", ref = "919fd6ac96095ea11fa7c940b17eaece49cc5993" } +simplifile = { version = ">= 2.5.0 and < 3.0.0" } +testcontainer = { version = ">= 1.0.2 and < 2.0.0" } +testcontainer_formulas = { version = ">= 1.0.0 and < 2.0.0" } +youid = { version = ">= 1.5.4 and < 2.0.0" } diff --git a/examples/course_subscriptions/src/course_subscription.gleam b/examples/course_subscriptions/src/course_subscription.gleam new file mode 100644 index 0000000..dc959bf --- /dev/null +++ b/examples/course_subscriptions/src/course_subscription.gleam @@ -0,0 +1,413 @@ +//// Enforce course and student subscription constraints with Dynamic +//// Consistency Boundaries. +//// +//// This implements the example at +//// https://dcb.events/examples/course-subscriptions/ using Factos and +//// PostgreSQL. +//// +//// ## One command flow +//// +//// A subscription command builds only the state needed to decide whether that +//// student can join that course: +//// +//// ```text +//// SubscribeStudentToCourse(student_id: "s1", course_id: "c1") +//// | +//// v +//// initial_state(command) +//// SubscribingStudent( +//// course: CourseMissing, +//// course_subscription_count: 0, +//// student_subscription_count: 0, +//// subscription: NotSubscribed, +//// ) +//// | +//// v +//// query(command) +//// course:c1 -> definition, capacity changes, subscriptions +//// student:s1 -> subscriptions to any course +//// | +//// v +//// evolve(state, event) for every matching stored event +//// | +//// v +//// SubscribingStudent( +//// course: CoursePresent(capacity: 2), +//// course_subscription_count: 1, +//// student_subscription_count: 3, +//// subscription: NotSubscribed, +//// ) +//// | +//// v +//// decide(state, command) +//// CourseMissing -> CourseDoesNotExist +//// course count >= capacity -> CourseFullyBooked +//// Subscribed -> StudentAlreadySubscribed +//// student count >= 5 -> StudentCourseLimitReached +//// otherwise -> StudentSubscribedToCourse +//// | +//// v +//// append the decided event +//// ``` +//// +//// The query is the Dynamic Consistency Boundary: it combines course-tagged and +//// student-tagged history, and `evolve` folds that history into the command-specific +//// state before `decide` applies the constraints. + +import factos +import factos/factos_pog +import gleam/bit_array +import gleam/dynamic/decode +import gleam/json +import gleam/result +import pog + +const student_course_limit = 5 + +pub type Event { + CourseDefined(course_id: String, capacity: Int) + CourseCapacityChanged(course_id: String, new_capacity: Int) + StudentSubscribedToCourse(student_id: String, course_id: String) +} + +pub type Command { + DefineCourse(course_id: String, capacity: Int) + ChangeCourseCapacity(course_id: String, new_capacity: Int) + SubscribeStudentToCourse(student_id: String, course_id: String) +} + +pub type Error { + CourseAlreadyExists(course_id: String) + CourseDoesNotExist(course_id: String) + CapacityUnchanged(capacity: Int) + CourseFullyBooked(course_id: String) + StudentAlreadySubscribed + StudentCourseLimitReached(limit: Int) +} + +type DefinitionStatus { + Undefined + Defined +} + +type CourseState { + CourseMissing + CoursePresent(capacity: Int) +} + +type SubscriptionStatus { + NotSubscribed + Subscribed +} + +type State { + DefiningCourse(definition: DefinitionStatus) + ChangingCourseCapacity(course: CourseState) + SubscribingStudent( + student_id: String, + course_id: String, + course: CourseState, + course_subscription_count: Int, + student_subscription_count: Int, + subscription: SubscriptionStatus, + ) +} + +fn initial(command: Command) -> State { + case command { + DefineCourse(course_id: _, capacity: _) -> + DefiningCourse(definition: Undefined) + ChangeCourseCapacity(course_id: _, new_capacity: _) -> + ChangingCourseCapacity(course: CourseMissing) + SubscribeStudentToCourse(student_id:, course_id:) -> + SubscribingStudent( + student_id:, + course_id:, + course: CourseMissing, + course_subscription_count: 0, + student_subscription_count: 0, + subscription: NotSubscribed, + ) + } +} + +fn decide(state: State, command: Command) -> Result(List(Event), Error) { + case state, command { + DefiningCourse(definition: Undefined), DefineCourse(course_id:, capacity:) + -> Ok([CourseDefined(course_id:, capacity:)]) + + DefiningCourse(definition: Defined), DefineCourse(course_id:, capacity: _) + -> Error(CourseAlreadyExists(course_id:)) + + ChangingCourseCapacity(course: CoursePresent(capacity:)), + ChangeCourseCapacity(course_id: _, new_capacity:) + if capacity == new_capacity + -> Error(CapacityUnchanged(capacity:)) + + ChangingCourseCapacity(course: CoursePresent(capacity: _)), + ChangeCourseCapacity(course_id:, new_capacity:) + -> Ok([CourseCapacityChanged(course_id:, new_capacity:)]) + + ChangingCourseCapacity(course: CourseMissing), + ChangeCourseCapacity(course_id:, new_capacity: _) + | SubscribingStudent(course: CourseMissing, ..), + SubscribeStudentToCourse(student_id: _, course_id:) + -> Error(CourseDoesNotExist(course_id:)) + + SubscribingStudent( + course: CoursePresent(capacity:), + course_subscription_count:, + .., + ), + SubscribeStudentToCourse(student_id: _, course_id:) + if course_subscription_count >= capacity + -> Error(CourseFullyBooked(course_id:)) + + SubscribingStudent( + course: CoursePresent(capacity: _), + subscription: Subscribed, + .., + ), + SubscribeStudentToCourse(student_id: _, course_id: _) + -> Error(StudentAlreadySubscribed) + + SubscribingStudent( + course: CoursePresent(capacity: _), + student_subscription_count:, + subscription: NotSubscribed, + .., + ), + SubscribeStudentToCourse(student_id: _, course_id: _) + if student_subscription_count >= student_course_limit + -> Error(StudentCourseLimitReached(limit: student_course_limit)) + + SubscribingStudent( + course: CoursePresent(capacity: _), + subscription: NotSubscribed, + .., + ), + SubscribeStudentToCourse(student_id:, course_id:) + -> Ok([StudentSubscribedToCourse(student_id:, course_id:)]) + + _, _ -> panic as "Command executed for wrong state" + } +} + +fn evolve(state: State, event: Event) -> State { + case state, event { + DefiningCourse(definition: _), CourseDefined(course_id: _, capacity: _) -> + DefiningCourse(definition: Defined) + + ChangingCourseCapacity(course: _), CourseDefined(course_id: _, capacity:) -> + ChangingCourseCapacity(course: CoursePresent(capacity:)) + + ChangingCourseCapacity(course: _), + CourseCapacityChanged(course_id: _, new_capacity:) + -> ChangingCourseCapacity(course: CoursePresent(capacity: new_capacity)) + + SubscribingStudent(course_id:, course:, ..), + CourseDefined(course_id: event_course_id, capacity:) + -> + SubscribingStudent(..state, course: case event_course_id == course_id { + True -> CoursePresent(capacity:) + False -> course + }) + + SubscribingStudent(course_id:, course:, ..), + CourseCapacityChanged(course_id: event_course_id, new_capacity:) + -> + SubscribingStudent(..state, course: case event_course_id == course_id { + True -> CoursePresent(capacity: new_capacity) + False -> course + }) + + SubscribingStudent( + student_id:, + course_id:, + course_subscription_count:, + student_subscription_count:, + subscription:, + .., + ), + StudentSubscribedToCourse( + student_id: event_student_id, + course_id: event_course_id, + ) + -> { + let targets_student = event_student_id == student_id + let targets_course = event_course_id == course_id + SubscribingStudent( + ..state, + course_subscription_count: case targets_course { + True -> course_subscription_count + 1 + False -> course_subscription_count + }, + student_subscription_count: case targets_student { + True -> student_subscription_count + 1 + False -> student_subscription_count + }, + subscription: case targets_student && targets_course { + True -> Subscribed + False -> subscription + }, + ) + } + + _, _ -> panic as "Event not belonging to command state" + } +} + +pub fn codec() -> factos_pog.EventCodec(Event) { + factos_pog.codec(encode: encode_event, decode: decode_event) +} + +fn encode_event(event: Event) -> factos_pog.Proposed(Event) { + case event { + CourseDefined(course_id:, capacity:) -> + factos_pog.new_proposed( + type_: factos.event_type("CourseDefined"), + version: 1, + data: json.object([ + #("course_id", json.string(course_id)), + #("capacity", json.int(capacity)), + ]) + |> json.to_string + |> bit_array.from_string, + ) + |> factos_pog.with_tags(tags: [ + factos.tag("course:" <> course_id), + ]) + CourseCapacityChanged(course_id:, new_capacity:) -> + factos_pog.new_proposed( + type_: factos.event_type("CourseCapacityChanged"), + version: 1, + data: json.object([ + #("course_id", json.string(course_id)), + #("new_capacity", json.int(new_capacity)), + ]) + |> json.to_string + |> bit_array.from_string, + ) + |> factos_pog.with_tags(tags: [ + factos.tag("course:" <> course_id), + ]) + StudentSubscribedToCourse(student_id:, course_id:) -> + factos_pog.new_proposed( + type_: factos.event_type("StudentSubscribedToCourse"), + version: 1, + data: json.object([ + #("student_id", json.string(student_id)), + #("course_id", json.string(course_id)), + ]) + |> json.to_string + |> bit_array.from_string, + ) + |> factos_pog.with_tags(tags: [ + factos.tag("student:" <> student_id), + factos.tag("course:" <> course_id), + ]) + } +} + +fn decode_event( + stored: factos_pog.StoredEvent, +) -> Result(factos.Decoded(Event), factos_pog.DecodeError) { + let decoder = case factos.event_type_name(stored.type_), stored.version { + "CourseDefined", 1 -> Ok(course_defined_decoder()) + "CourseCapacityChanged", 1 -> Ok(course_capacity_changed_decoder()) + "StudentSubscribedToCourse", 1 -> Ok(student_subscribed_decoder()) + _, _ -> Error(factos_pog.UnknownEvent) + } + use decoder <- result.try(decoder) + use event <- result.try( + json.parse_bits(from: stored.data, using: decoder) + |> result.replace_error(factos_pog.InvalidData), + ) + factos_pog.decoded_event(stored, event:) +} + +fn course_defined_decoder() -> decode.Decoder(Event) { + use course_id <- decode.field("course_id", decode.string) + use capacity <- decode.field("capacity", decode.int) + decode.success(CourseDefined(course_id:, capacity:)) +} + +fn course_capacity_changed_decoder() -> decode.Decoder(Event) { + use course_id <- decode.field("course_id", decode.string) + use new_capacity <- decode.field("new_capacity", decode.int) + decode.success(CourseCapacityChanged(course_id:, new_capacity:)) +} + +fn student_subscribed_decoder() -> decode.Decoder(Event) { + use student_id <- decode.field("student_id", decode.string) + use course_id <- decode.field("course_id", decode.string) + decode.success(StudentSubscribedToCourse(student_id:, course_id:)) +} + +pub fn dispatcher() -> Result( + factos_pog.Dispatcher(Event), + factos_pog.DispatcherConfigurationError, +) { + factos_pog.dispatcher(codec: codec(), subscriptions: []) +} + +pub fn dispatch( + connection: pog.Connection, + dispatcher: factos_pog.Dispatcher(Event), + command: Command, + event_id: fn() -> String, +) -> Result(factos_pog.Dispatch(Event), factos_pog.Error(Error)) { + factos_pog.new_dispatch( + connection:, + stream: stream(command), + decider: factos.decider(initial: initial(command), decide:, evolve:), + dispatcher:, + ) + |> factos_pog.with_query(query(command)) + |> factos_pog.dispatch(command, event_id:) +} + +fn query(command: Command) -> factos.Query { + case command { + DefineCourse(course_id:, capacity: _) -> + factos.query([ + factos.query_item(types: [factos.event_type("CourseDefined")], tags: [ + factos.tag("course:" <> course_id), + ]), + ]) + ChangeCourseCapacity(course_id:, new_capacity: _) -> + factos.query([ + factos.query_item( + types: [ + factos.event_type("CourseDefined"), + factos.event_type("CourseCapacityChanged"), + ], + tags: [factos.tag("course:" <> course_id)], + ), + ]) + SubscribeStudentToCourse(student_id:, course_id:) -> + factos.query([ + factos.query_item( + types: [ + factos.event_type("CourseDefined"), + factos.event_type("CourseCapacityChanged"), + factos.event_type("StudentSubscribedToCourse"), + ], + tags: [factos.tag("course:" <> course_id)], + ), + factos.query_item( + types: [factos.event_type("StudentSubscribedToCourse")], + tags: [factos.tag("student:" <> student_id)], + ), + ]) + } +} + +fn stream(command: Command) { + case command { + DefineCourse(course_id:, ..) | ChangeCourseCapacity(course_id:, ..) -> + "course-" <> course_id + SubscribeStudentToCourse(student_id:, course_id:) -> + "subscription-" <> course_id <> ":" <> student_id + } +} diff --git a/examples/course_subscriptions/test/course_subscriptions_test.gleam b/examples/course_subscriptions/test/course_subscriptions_test.gleam new file mode 100644 index 0000000..401f94a --- /dev/null +++ b/examples/course_subscriptions/test/course_subscriptions_test.gleam @@ -0,0 +1,26 @@ +import course_subscriptions_dev +import gleeunit + +pub type Timeout(a) { + Timeout(time: Int, function: fn() -> a) +} + +pub fn main() -> Nil { + gleeunit.main() +} + +pub fn course_subscription_example_test_() -> Timeout(Nil) { + use <- Timeout(120) + let assert Ok(result) = course_subscriptions_dev.run() + assert result + == course_subscriptions_dev.ExampleResult( + defined_courses: 8, + capacity_changes: 1, + accepted_subscriptions: 9, + rejected_constraints: 8, + concurrent_acceptances: 1, + concurrent_rejections: 1, + stored_events: 18, + ) + Nil +} diff --git a/examples/dynamic_product_price/.gitignore b/examples/dynamic_product_price/.gitignore new file mode 100644 index 0000000..982745b --- /dev/null +++ b/examples/dynamic_product_price/.gitignore @@ -0,0 +1,7 @@ +*.beam +*.ez +/build +**/build +node_modules +**/node_modules +erl_crash.dump diff --git a/examples/dynamic_product_price/dev/dynamic_product_price_dev.gleam b/examples/dynamic_product_price/dev/dynamic_product_price_dev.gleam new file mode 100644 index 0000000..3076285 --- /dev/null +++ b/examples/dynamic_product_price/dev/dynamic_product_price_dev.gleam @@ -0,0 +1,478 @@ +import dynamic_product_price +import factos +import factos/factos_pog +import gleam/erlang/application +import gleam/erlang/process +import gleam/io +import gleam/list +import gleam/option +import gleam/otp/actor +import gleam/string +import pog +import simplifile +import testcontainer +import testcontainer/error as testcontainer_error +import testcontainer_formulas/postgres +import youid/uuid + +pub type ExampleResult { + ExampleResult( + product_definitions: Int, + price_changes: Int, + accepted_orders: Int, + rejected_orders: Int, + concurrent_acceptances: Int, + stored_events: Int, + ) +} + +type WorkerMessage { + WorkerReady(worker: String, release: process.Subject(Nil)) + WorkerFinished( + worker: String, + result: Result( + factos_pog.Dispatch(dynamic_product_price.Event), + factos_pog.Error(dynamic_product_price.Error), + ), + ) +} + +pub fn run() -> Result(ExampleResult, testcontainer_error.Error) { + use postgres_container <- testcontainer.with_formula( + postgres.new() |> postgres.formula(), + ) + let #(pool_pid, connection) = start_connection(postgres_container) + install_event_store(connection) + let dispatcher = configured_dispatcher() + + require_definition( + connection, + dispatcher, + product_id: "p1", + price: 123, + recorded_minute: 0, + ) + require_invalid_order( + dynamic_product_price.dispatch( + connection, + dispatcher, + dynamic_product_price.OrderProducts( + order_id: "invalid-never", + items: [ + dynamic_product_price.OrderItem( + product_id: "p1", + displayed_price: 100, + ), + ], + current_minute: 20, + ), + uuid.v4_string, + ), + product_id: "p1", + ) + require_order( + connection, + dispatcher, + order_id: "initial-price", + items: [ + dynamic_product_price.OrderItem(product_id: "p1", displayed_price: 123), + ], + current_minute: 20, + ) + + let assert Ok(_) = + dynamic_product_price.dispatch( + connection, + dispatcher, + dynamic_product_price.ChangeProductPrice( + product_id: "p1", + new_price: 134, + recorded_minute: 11, + ), + uuid.v4_string, + ) + require_order( + connection, + dispatcher, + order_id: "old-within-grace", + items: [ + dynamic_product_price.OrderItem(product_id: "p1", displayed_price: 123), + ], + current_minute: 20, + ) + require_order( + connection, + dispatcher, + order_id: "new-within-grace", + items: [ + dynamic_product_price.OrderItem(product_id: "p1", displayed_price: 134), + ], + current_minute: 20, + ) + require_invalid_order( + dynamic_product_price.dispatch( + connection, + dispatcher, + dynamic_product_price.OrderProducts( + order_id: "old-after-grace", + items: [ + dynamic_product_price.OrderItem( + product_id: "p1", + displayed_price: 123, + ), + ], + current_minute: 22, + ), + uuid.v4_string, + ), + product_id: "p1", + ) + require_order( + connection, + dispatcher, + order_id: "new-after-grace", + items: [ + dynamic_product_price.OrderItem(product_id: "p1", displayed_price: 134), + ], + current_minute: 22, + ) + + require_definition( + connection, + dispatcher, + product_id: "p2", + price: 321, + recorded_minute: 12, + ) + let valid_cart = [ + dynamic_product_price.OrderItem(product_id: "p1", displayed_price: 134), + dynamic_product_price.OrderItem(product_id: "p2", displayed_price: 321), + ] + require_order( + connection, + dispatcher, + order_id: "valid-cart", + items: valid_cart, + current_minute: 22, + ) + require_invalid_order( + dynamic_product_price.dispatch( + connection, + dispatcher, + dynamic_product_price.OrderProducts( + order_id: "invalid-cart", + items: [ + dynamic_product_price.OrderItem( + product_id: "p1", + displayed_price: 134, + ), + dynamic_product_price.OrderItem( + product_id: "p2", + displayed_price: 999, + ), + ], + current_minute: 22, + ), + uuid.v4_string, + ), + product_id: "p2", + ) + + let concurrent_results = + run_concurrent_orders(connection, dispatcher, valid_cart) + let concurrent_acceptances = + list.count(concurrent_results, where: is_accepted) + assert concurrent_acceptances == 2 + + let assert Ok(events) = + factos_pog.read_events_after( + connection, + query: factos.AllEvents, + after: factos.NoPosition, + limit: 100, + codec: dynamic_product_price.codec(), + ) + assert list.count(events, where: is_product_defined) == 2 + assert list.count(events, where: is_price_changed) == 1 + assert list.count(events, where: is_products_ordered) == 7 + assert list.length(events) == 10 + + process.send_exit(pool_pid) + process.sleep(100) + Ok(ExampleResult( + product_definitions: 2, + price_changes: 1, + accepted_orders: 7, + rejected_orders: 3, + concurrent_acceptances:, + stored_events: list.length(events), + )) +} + +pub fn main() -> Nil { + let assert Ok(ExampleResult( + product_definitions: 2, + price_changes: 1, + accepted_orders: 7, + rejected_orders: 3, + concurrent_acceptances: 2, + stored_events: 10, + )) = run() + io.println("never-valid displayed price: rejected") + io.println("initial product price: accepted") + io.println("old price within 10-minute grace period: accepted") + io.println("old price after grace period: rejected") + io.println("new product price: accepted") + io.println("multi-product cart: validated atomically") + io.println("parallel valid carts: 2 accepted") + io.println("persisted events: 10") +} + +fn configured_dispatcher() -> factos_pog.Dispatcher(dynamic_product_price.Event) { + let assert Ok(dispatcher) = dynamic_product_price.dispatcher() + dispatcher +} + +fn require_definition( + connection: pog.Connection, + dispatcher: factos_pog.Dispatcher(dynamic_product_price.Event), + product_id product_id: String, + price price: Int, + recorded_minute recorded_minute: Int, +) -> Nil { + let assert Ok(_) = + dynamic_product_price.dispatch( + connection, + dispatcher, + dynamic_product_price.DefineProduct(product_id:, price:, recorded_minute:), + uuid.v4_string, + ) + Nil +} + +fn require_order( + connection: pog.Connection, + dispatcher: factos_pog.Dispatcher(dynamic_product_price.Event), + order_id order_id: String, + items items: List(dynamic_product_price.OrderItem), + current_minute current_minute: Int, +) -> Nil { + let assert Ok(dispatch) = + dynamic_product_price.dispatch( + connection, + dispatcher, + dynamic_product_price.OrderProducts(order_id:, items:, current_minute:), + uuid.v4_string, + ) + let assert [recorded] = dispatch.events + let expected_items = + list.map(items, fn(item) { + let dynamic_product_price.OrderItem(product_id:, displayed_price:) = item + dynamic_product_price.OrderedItem(product_id:, price: displayed_price) + }) + assert recorded.event + == dynamic_product_price.ProductsOrdered(items: expected_items) + Nil +} + +fn require_invalid_order( + result: Result( + factos_pog.Dispatch(dynamic_product_price.Event), + factos_pog.Error(dynamic_product_price.Error), + ), + product_id product_id: String, +) -> Nil { + let assert Error(factos_pog.DomainError(dynamic_product_price.InvalidPrice( + product_id: invalid_product_id, + ))) = result + assert invalid_product_id == product_id + Nil +} + +fn run_concurrent_orders( + connection: pog.Connection, + dispatcher: factos_pog.Dispatcher(dynamic_product_price.Event), + items: List(dynamic_product_price.OrderItem), +) -> List( + Result( + factos_pog.Dispatch(dynamic_product_price.Event), + factos_pog.Error(dynamic_product_price.Error), + ), +) { + let messages = process.new_subject() + start_worker( + connection, + dispatcher:, + messages:, + worker: "first", + order_id: "parallel-a", + items:, + ) + start_worker( + connection, + dispatcher:, + messages:, + worker: "second", + order_id: "parallel-b", + items:, + ) + + let first_release = receive_worker_ready(messages) + let second_release = receive_worker_ready(messages) + process.send(first_release, Nil) + process.send(second_release, Nil) + [ + receive_worker_finished(messages), + receive_worker_finished(messages), + ] +} + +fn start_worker( + connection: pog.Connection, + dispatcher dispatcher: factos_pog.Dispatcher(dynamic_product_price.Event), + messages messages: process.Subject(WorkerMessage), + worker worker: String, + order_id order_id: String, + items items: List(dynamic_product_price.OrderItem), +) -> process.Pid { + process.spawn(fn() { + let release = process.new_subject() + process.send(messages, WorkerReady(worker:, release:)) + let assert Ok(Nil) = process.receive(release, within: 10_000) + let result = + dynamic_product_price.dispatch( + connection, + dispatcher, + dynamic_product_price.OrderProducts( + order_id:, + items:, + current_minute: 22, + ), + uuid.v4_string, + ) + process.send(messages, WorkerFinished(worker:, result:)) + }) +} + +fn receive_worker_ready( + messages: process.Subject(WorkerMessage), +) -> process.Subject(Nil) { + let assert Ok(message) = process.receive(messages, within: 10_000) + let assert WorkerReady(worker: _, release:) = message + release +} + +fn receive_worker_finished( + messages: process.Subject(WorkerMessage), +) -> Result( + factos_pog.Dispatch(dynamic_product_price.Event), + factos_pog.Error(dynamic_product_price.Error), +) { + let assert Ok(message) = process.receive(messages, within: 10_000) + let assert WorkerFinished(worker: _, result:) = message + result +} + +fn is_accepted( + result: Result( + factos_pog.Dispatch(dynamic_product_price.Event), + factos_pog.Error(dynamic_product_price.Error), + ), +) -> Bool { + case result { + Ok(_) -> True + Error(_) -> False + } +} + +fn is_product_defined( + recorded: factos.Recorded(dynamic_product_price.Event), +) -> Bool { + case recorded.event { + dynamic_product_price.ProductDefined( + product_id: _, + price: _, + recorded_minute: _, + ) -> True + dynamic_product_price.ProductPriceChanged( + product_id: _, + new_price: _, + recorded_minute: _, + ) + | dynamic_product_price.ProductsOrdered(items: _) -> False + } +} + +fn is_price_changed( + recorded: factos.Recorded(dynamic_product_price.Event), +) -> Bool { + case recorded.event { + dynamic_product_price.ProductPriceChanged( + product_id: _, + new_price: _, + recorded_minute: _, + ) -> True + dynamic_product_price.ProductDefined( + product_id: _, + price: _, + recorded_minute: _, + ) + | dynamic_product_price.ProductsOrdered(items: _) -> False + } +} + +fn is_products_ordered( + recorded: factos.Recorded(dynamic_product_price.Event), +) -> Bool { + case recorded.event { + dynamic_product_price.ProductsOrdered(items: _) -> True + dynamic_product_price.ProductDefined( + product_id: _, + price: _, + recorded_minute: _, + ) + | dynamic_product_price.ProductPriceChanged( + product_id: _, + new_price: _, + recorded_minute: _, + ) -> False + } +} + +fn start_connection( + postgres_container: postgres.PostgresContainer, +) -> #(process.Pid, pog.Connection) { + let postgres.PostgresContainer(host:, port:, database:, username:, ..) = + postgres_container + let pool_name = process.new_name("dynamic_product_price") + let config = + pog.default_config(pool_name) + |> pog.host(host) + |> pog.port(port) + |> pog.database(database) + |> pog.user(username) + |> pog.password(option.Some("postgres")) + |> pog.ssl(pog.SslDisabled) + + let assert Ok(actor.Started(pid:, ..)) = pog.start(config) + process.sleep(100) + #(pid, pog.named_connection(pool_name)) +} + +fn install_event_store(connection: pog.Connection) -> Nil { + let assert Ok(priv_directory) = application.priv_directory("factos_pog") + let assert Ok(sql) = + simplifile.read( + priv_directory <> "/dbmate/20260703000100_factos_pog_event_store.sql", + ) + let assert [up, ..] = string.split(sql, "-- migrate:down") + + up + |> string.split(";") + |> list.map(string.trim) + |> list.filter(fn(statement) { statement != "" }) + |> list.each(fn(statement) { + let assert Ok(_) = pog.query(statement) |> pog.execute(on: connection) + Nil + }) +} diff --git a/examples/dynamic_product_price/gleam.toml b/examples/dynamic_product_price/gleam.toml new file mode 100644 index 0000000..8568594 --- /dev/null +++ b/examples/dynamic_product_price/gleam.toml @@ -0,0 +1,18 @@ +name = "dynamic_product_price" +version = "1.0.0" + +[dependencies] +factos = { path = "../.." } +factos_pog = { path = "../../backends/factos_pog" } +gleam_json = ">= 3.1.0 and < 4.0.0" +gleam_stdlib = ">= 1.0.0 and < 2.0.0" +pog = { git = "https://github.com/foxfriends/pog.git", ref = "919fd6ac96095ea11fa7c940b17eaece49cc5993" } + +[dev_dependencies] +gleam_erlang = ">= 1.0.0 and < 2.0.0" +gleam_otp = ">= 1.2.0 and < 2.0.0" +gleeunit = ">= 1.0.0 and < 2.0.0" +simplifile = ">= 2.5.0 and < 3.0.0" +testcontainer = ">= 1.0.2 and < 2.0.0" +testcontainer_formulas = ">= 1.0.0 and < 2.0.0" +youid = ">= 1.5.4 and < 2.0.0" diff --git a/examples/dynamic_product_price/manifest.toml b/examples/dynamic_product_price/manifest.toml new file mode 100644 index 0000000..7f06b78 --- /dev/null +++ b/examples/dynamic_product_price/manifest.toml @@ -0,0 +1,46 @@ +# Do not manually edit this file, it is managed by Gleam. +# +# This file locks the dependency versions used, to make your build +# deterministic and to prevent unexpected versions from being included +# in your application. +# +# You should check this file into your source control repository. + +packages = [ + { name = "backoff", version = "1.1.6", build_tools = ["rebar3"], requirements = [], otp_app = "backoff", source = "hex", outer_checksum = "CF0CFFF8995FB20562F822E5CC47D8CCF664C5ECDC26A684CBE85C225F9D7C39" }, + { name = "cowl", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "cowl", source = "hex", outer_checksum = "7849E7C789D7228243A4253138FC883720A0BB44AEF406102328CADC64C3CA2B" }, + { name = "envie", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envie", source = "hex", outer_checksum = "E7EBA39310F32A40BF3EDDD7CD9C7A2BC289909983D357411C22873415BC322A" }, + { name = "exception", version = "2.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "6BDEA95248093599391C3B5DF1835C5C6A86C353C2F99CE539B450E3432FE117" }, + { name = "factos", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], source = "local", path = "../.." }, + { name = "factos_pog", version = "2.0.0", build_tools = ["gleam"], requirements = ["factos", "gleam_erlang", "gleam_otp", "gleam_stdlib", "gleam_time", "pog"], source = "local", path = "../../backends/factos_pog" }, + { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, + { name = "gleam_crypto", version = "1.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "2DE9E4EF53CF6FEE049D4F765731F7178F7A11AEFAE00EEE63BF7536B354AD3F" }, + { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, + { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, + { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, + { name = "gleam_stdlib", version = "1.0.5", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "CEE5B6C076A85B45F60C585F4316C63EC8B7127C119D5738C3958A9C4D50404E" }, + { name = "gleam_time", version = "1.8.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "533D8723774D61AD4998324F5DD1DABDCDBFABAFB9E87CB5D03C6955448FC97D" }, + { name = "gleeunit", version = "1.11.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "EC31ABA74256AEA531EDF8169931D775BBB384FED0A8A1BDC4DD9354E3E21826" }, + { name = "opentelemetry_api", version = "1.5.0", build_tools = ["rebar3", "mix"], requirements = [], otp_app = "opentelemetry_api", source = "hex", outer_checksum = "F53EC8A1337AE4A487D43AC89DA4BD3A3C99DDF576655D071DEED8B56A2D5DDA" }, + { name = "pg_types", version = "0.6.0", build_tools = ["rebar3"], requirements = [], otp_app = "pg_types", source = "hex", outer_checksum = "9949A4849DD13408FA249AB7B745E0D2DFDB9532AEE2B9722326E33CD082A778" }, + { name = "pgo", version = "0.20.0", build_tools = ["rebar3"], requirements = ["backoff", "opentelemetry_api", "pg_types"], otp_app = "pgo", source = "hex", outer_checksum = "2F11E6649CEB38E569EF56B16BE1D04874AE5B11A02867080A2817CE423C683B" }, + { name = "pog", version = "4.1.0", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_otp", "gleam_stdlib", "gleam_time", "pgo"], source = "git", repo = "https://github.com/foxfriends/pog.git", commit = "919fd6ac96095ea11fa7c940b17eaece49cc5993" }, + { name = "simplifile", version = "2.7.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "A2727627B063E87351934C7F7F008F2D1FDB16F6DE0B8C79F9E46459CFC9C164" }, + { name = "testcontainer", version = "1.0.2", build_tools = ["gleam"], requirements = ["cowl", "envie", "gleam_erlang", "gleam_json", "gleam_stdlib"], otp_app = "testcontainer", source = "hex", outer_checksum = "784768485ED2380AA543A0CC3F06F7A368B0DD209102C57E800E0A87E1D2FC81" }, + { name = "testcontainer_formulas", version = "1.0.0", build_tools = ["gleam"], requirements = ["cowl", "gleam_stdlib", "testcontainer"], otp_app = "testcontainer_formulas", source = "hex", outer_checksum = "F9A86A2F8400A0C72FE98F56EF5B3FD1CE10F0A63D968A1C57EA0087A3E5802B" }, + { name = "youid", version = "1.6.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_stdlib", "gleam_time"], otp_app = "youid", source = "hex", outer_checksum = "7A3ABA44B1B38BC2BDCB5474C5317AA372BE58DFBC649815EE08B03526DDA18D" }, +] + +[requirements] +factos = { path = "../.." } +factos_pog = { path = "../../backends/factos_pog" } +gleam_erlang = { version = ">= 1.0.0 and < 2.0.0" } +gleam_json = { version = ">= 3.1.0 and < 4.0.0" } +gleam_otp = { version = ">= 1.2.0 and < 2.0.0" } +gleam_stdlib = { version = ">= 1.0.0 and < 2.0.0" } +gleeunit = { version = ">= 1.0.0 and < 2.0.0" } +pog = { git = "https://github.com/foxfriends/pog.git", ref = "919fd6ac96095ea11fa7c940b17eaece49cc5993" } +simplifile = { version = ">= 2.5.0 and < 3.0.0" } +testcontainer = { version = ">= 1.0.2 and < 2.0.0" } +testcontainer_formulas = { version = ">= 1.0.0 and < 2.0.0" } +youid = { version = ">= 1.5.4 and < 2.0.0" } diff --git a/examples/dynamic_product_price/src/dynamic_product_price.gleam b/examples/dynamic_product_price/src/dynamic_product_price.gleam new file mode 100644 index 0000000..6201a08 --- /dev/null +++ b/examples/dynamic_product_price/src/dynamic_product_price.gleam @@ -0,0 +1,450 @@ +//// Validate a shopping cart's displayed prices with Dynamic Consistency +//// Boundaries. +//// +//// This implements the example at +//// https://dcb.events/examples/dynamic-product-price/ using Factos and +//// PostgreSQL. The source's relative `minutesAgo` metadata is represented by +//// absolute recorded and current minutes, keeping retrying decisions pure. + +import factos +import factos/factos_pog +import gleam/bit_array +import gleam/dynamic/decode +import gleam/int +import gleam/json +import gleam/list +import gleam/result +import pog + +const price_grace_period_minutes = 10 + +const recorded_minute_key = "recorded_minute" + +pub type OrderItem { + OrderItem(product_id: String, displayed_price: Int) +} + +pub type OrderedItem { + OrderedItem(product_id: String, price: Int) +} + +pub type Event { + ProductDefined(product_id: String, price: Int, recorded_minute: Int) + ProductPriceChanged(product_id: String, new_price: Int, recorded_minute: Int) + ProductsOrdered(items: List(OrderedItem)) +} + +pub type Command { + DefineProduct(product_id: String, price: Int, recorded_minute: Int) + ChangeProductPrice(product_id: String, new_price: Int, recorded_minute: Int) + OrderProducts(order_id: String, items: List(OrderItem), current_minute: Int) +} + +pub type Error { + InvalidPrice(product_id: String) +} + +type StablePrice { + NoStablePrice + StablePrice(price: Int) +} + +type ProductPrice { + ProductPrice( + product_id: String, + stable_price: StablePrice, + recent_prices: List(Int), + ) +} + +type State { + RecordingPriceFact + OrderingProducts(current_minute: Int, products: List(ProductPrice)) +} + +type PriceAge { + WithinGracePeriod + OutsideGracePeriod +} + +fn initial(command: Command) -> State { + case command { + DefineProduct(product_id: _, price: _, recorded_minute: _) + | ChangeProductPrice(product_id: _, new_price: _, recorded_minute: _) -> + RecordingPriceFact + OrderProducts(order_id: _, items:, current_minute:) -> + OrderingProducts( + current_minute:, + products: list.map(items, fn(item) { + let OrderItem(product_id:, displayed_price: _) = item + ProductPrice( + product_id:, + stable_price: NoStablePrice, + recent_prices: [], + ) + }), + ) + } +} + +fn decide(state: State, command: Command) -> Result(List(Event), Error) { + case state, command { + RecordingPriceFact, DefineProduct(product_id:, price:, recorded_minute:) -> + Ok([ProductDefined(product_id:, price:, recorded_minute:)]) + RecordingPriceFact, + ChangeProductPrice(product_id:, new_price:, recorded_minute:) + -> Ok([ProductPriceChanged(product_id:, new_price:, recorded_minute:)]) + OrderingProducts(current_minute: _, products:), + OrderProducts(order_id: _, items:, current_minute: _) + -> { + use _ <- result.try(validate_prices(items, products)) + Ok([ProductsOrdered(items: list.map(items, ordered_item))]) + } + _, _ -> panic as "Command executed for wrong state" + } +} + +fn ordered_item(item: OrderItem) -> OrderedItem { + let OrderItem(product_id:, displayed_price:) = item + OrderedItem(product_id:, price: displayed_price) +} + +fn validate_prices( + items: List(OrderItem), + prices: List(ProductPrice), +) -> Result(Nil, Error) { + case items { + [] -> Ok(Nil) + [OrderItem(product_id:, displayed_price:), ..remaining] -> { + use price <- result.try( + list.find(prices, fn(price) { + let ProductPrice(product_id: price_product_id, ..) = price + price_product_id == product_id + }) + |> result.map_error(fn(_) { InvalidPrice(product_id:) }), + ) + let ProductPrice(stable_price:, recent_prices:, ..) = price + case + matches_stable_price(stable_price, displayed_price) + || list.contains(recent_prices, displayed_price) + { + True -> validate_prices(remaining, prices) + False -> Error(InvalidPrice(product_id:)) + } + } + } +} + +fn matches_stable_price( + stable_price: StablePrice, + displayed_price: Int, +) -> Bool { + case stable_price { + NoStablePrice -> False + StablePrice(price:) -> price == displayed_price + } +} + +fn evolve(state: State, event: Event) -> State { + case state, event { + RecordingPriceFact, + ProductDefined(product_id: _, price: _, recorded_minute: _) + | RecordingPriceFact, + ProductPriceChanged(product_id: _, new_price: _, recorded_minute: _) + | RecordingPriceFact, ProductsOrdered(items: _) + -> RecordingPriceFact + OrderingProducts(current_minute:, products:), + ProductDefined(product_id:, price:, recorded_minute:) + -> + OrderingProducts( + ..state, + products: list.map(products, fn(product) { + update_defined_price( + product, + product_id, + price, + classify_age(current_minute, recorded_minute), + ) + }), + ) + OrderingProducts(current_minute:, products:), + ProductPriceChanged(product_id:, new_price:, recorded_minute:) + -> + OrderingProducts( + ..state, + products: list.map(products, fn(product) { + update_changed_price( + product, + product_id, + new_price, + classify_age(current_minute, recorded_minute), + ) + }), + ) + OrderingProducts(current_minute: _, products: _), ProductsOrdered(items: _) + -> state + } +} + +fn update_defined_price( + product: ProductPrice, + product_id: String, + price: Int, + age: PriceAge, +) -> ProductPrice { + let ProductPrice(product_id: target_product_id, ..) = product + case target_product_id == product_id, age { + False, _ -> product + True, WithinGracePeriod -> + ProductPrice(..product, stable_price: NoStablePrice, recent_prices: [ + price, + ]) + True, OutsideGracePeriod -> + ProductPrice( + ..product, + stable_price: StablePrice(price:), + recent_prices: [], + ) + } +} + +fn update_changed_price( + product: ProductPrice, + product_id: String, + new_price: Int, + age: PriceAge, +) -> ProductPrice { + let ProductPrice(product_id: target_product_id, recent_prices:, ..) = product + case target_product_id == product_id, age { + False, _ -> product + True, WithinGracePeriod -> + ProductPrice( + ..product, + recent_prices: list.append(recent_prices, [new_price]), + ) + True, OutsideGracePeriod -> + ProductPrice(..product, stable_price: StablePrice(price: new_price)) + } +} + +fn classify_age(current_minute: Int, recorded_minute: Int) -> PriceAge { + case current_minute - recorded_minute <= price_grace_period_minutes { + True -> WithinGracePeriod + False -> OutsideGracePeriod + } +} + +pub fn codec() -> factos_pog.EventCodec(Event) { + factos_pog.codec(encode: encode_event, decode: decode_event) +} + +fn encode_event(event: Event) -> factos_pog.Proposed(Event) { + case event { + ProductDefined(product_id:, price:, recorded_minute:) -> + proposed_event( + type_: "ProductDefined", + data: json.object([ + #("product_id", json.string(product_id)), + #("price", json.int(price)), + ]), + tags: [factos.tag("product:" <> product_id)], + ) + |> with_recorded_minute(recorded_minute) + ProductPriceChanged(product_id:, new_price:, recorded_minute:) -> + proposed_event( + type_: "ProductPriceChanged", + data: json.object([ + #("product_id", json.string(product_id)), + #("new_price", json.int(new_price)), + ]), + tags: [factos.tag("product:" <> product_id)], + ) + |> with_recorded_minute(recorded_minute) + ProductsOrdered(items:) -> + proposed_event( + type_: "ProductsOrdered", + data: json.object([ + #("items", json.array(from: items, of: encode_ordered_item)), + ]), + tags: list.map(items, fn(item) { + let OrderedItem(product_id:, price: _) = item + factos.tag("product:" <> product_id) + }), + ) + } +} + +fn encode_ordered_item(item: OrderedItem) -> json.Json { + let OrderedItem(product_id:, price:) = item + json.object([ + #("product_id", json.string(product_id)), + #("price", json.int(price)), + ]) +} + +fn proposed_event( + type_ type_name: String, + data data: json.Json, + tags tags: List(factos.Tag), +) -> factos_pog.Proposed(Event) { + factos_pog.new_proposed( + type_: factos.event_type(type_name), + version: 1, + data: data |> json.to_string |> bit_array.from_string, + ) + |> factos_pog.with_tags(tags:) +} + +fn with_recorded_minute( + proposed: factos_pog.Proposed(Event), + recorded_minute: Int, +) -> factos_pog.Proposed(Event) { + factos_pog.with_metadata( + proposed, + metadata: factos.metadata([ + #(recorded_minute_key, int.to_string(recorded_minute)), + ]), + ) +} + +fn decode_event( + stored: factos_pog.StoredEvent, +) -> Result(factos.Decoded(Event), factos_pog.DecodeError) { + case factos.event_type_name(stored.type_), stored.version { + "ProductDefined", 1 -> { + use data <- result.try(decode_payload(stored, product_defined_decoder())) + use recorded_minute <- result.try(decode_recorded_minute(stored.metadata)) + factos_pog.decoded_event( + stored, + event: ProductDefined( + product_id: data.0, + price: data.1, + recorded_minute:, + ), + ) + } + "ProductPriceChanged", 1 -> { + use data <- result.try(decode_payload( + stored, + product_price_changed_decoder(), + )) + use recorded_minute <- result.try(decode_recorded_minute(stored.metadata)) + factos_pog.decoded_event( + stored, + event: ProductPriceChanged( + product_id: data.0, + new_price: data.1, + recorded_minute:, + ), + ) + } + "ProductsOrdered", 1 -> { + use items <- result.try(decode_payload(stored, ordered_items_decoder())) + factos_pog.decoded_event(stored, event: ProductsOrdered(items:)) + } + _, _ -> Error(factos_pog.UnknownEvent) + } +} + +fn decode_payload( + stored: factos_pog.StoredEvent, + decoder: decode.Decoder(value), +) -> Result(value, factos_pog.DecodeError) { + json.parse_bits(from: stored.data, using: decoder) + |> result.replace_error(factos_pog.InvalidData) +} + +fn decode_recorded_minute( + metadata: factos.Metadata, +) -> Result(Int, factos_pog.DecodeError) { + use value <- result.try( + factos.metadata_get(metadata, recorded_minute_key) + |> result.replace_error(factos_pog.InvalidData), + ) + int.parse(value) |> result.replace_error(factos_pog.InvalidData) +} + +fn product_defined_decoder() -> decode.Decoder(#(String, Int)) { + use product_id <- decode.field("product_id", decode.string) + use price <- decode.field("price", decode.int) + decode.success(#(product_id, price)) +} + +fn product_price_changed_decoder() -> decode.Decoder(#(String, Int)) { + use product_id <- decode.field("product_id", decode.string) + use new_price <- decode.field("new_price", decode.int) + decode.success(#(product_id, new_price)) +} + +fn ordered_items_decoder() -> decode.Decoder(List(OrderedItem)) { + use items <- decode.field("items", decode.list(ordered_item_decoder())) + decode.success(items) +} + +fn ordered_item_decoder() -> decode.Decoder(OrderedItem) { + use product_id <- decode.field("product_id", decode.string) + use price <- decode.field("price", decode.int) + decode.success(OrderedItem(product_id:, price:)) +} + +pub fn dispatcher() -> Result( + factos_pog.Dispatcher(Event), + factos_pog.DispatcherConfigurationError, +) { + factos_pog.dispatcher(codec: codec(), subscriptions: []) +} + +pub fn dispatch( + connection: pog.Connection, + dispatcher: factos_pog.Dispatcher(Event), + command: Command, + event_id: fn() -> String, +) -> Result(factos_pog.Dispatch(Event), factos_pog.Error(Error)) { + factos_pog.new_dispatch( + connection:, + stream: stream(command), + decider: factos.decider(initial: initial(command), decide:, evolve:), + dispatcher:, + ) + |> factos_pog.with_query(query(command)) + |> factos_pog.dispatch(command, event_id:) +} + +fn query(command: Command) -> factos.Query { + case command { + DefineProduct(product_id:, price: _, recorded_minute: _) + | ChangeProductPrice(product_id:, new_price: _, recorded_minute: _) -> + factos.query([ + factos.query_item( + types: [ + factos.event_type("ProductDefined"), + factos.event_type("ProductPriceChanged"), + ], + tags: [factos.tag("product:" <> product_id)], + ), + ]) + OrderProducts(order_id: _, items:, current_minute: _) -> + items + |> list.map(fn(item) { + let OrderItem(product_id:, displayed_price: _) = item + factos.query_item( + types: [ + factos.event_type("ProductDefined"), + factos.event_type("ProductPriceChanged"), + ], + tags: [factos.tag("product:" <> product_id)], + ) + }) + |> factos.query + } +} + +fn stream(command: Command) -> String { + case command { + DefineProduct(product_id:, price: _, recorded_minute: _) + | ChangeProductPrice(product_id:, new_price: _, recorded_minute: _) -> + "product-" <> product_id + OrderProducts(order_id:, items: _, current_minute: _) -> + "order-" <> order_id + } +} diff --git a/examples/dynamic_product_price/test/dynamic_product_price_test.gleam b/examples/dynamic_product_price/test/dynamic_product_price_test.gleam new file mode 100644 index 0000000..e2f011a --- /dev/null +++ b/examples/dynamic_product_price/test/dynamic_product_price_test.gleam @@ -0,0 +1,25 @@ +import dynamic_product_price_dev +import gleeunit + +pub type Timeout(a) { + Timeout(time: Int, function: fn() -> a) +} + +pub fn main() -> Nil { + gleeunit.main() +} + +pub fn dynamic_product_price_example_test_() -> Timeout(Nil) { + use <- Timeout(120) + let assert Ok(result) = dynamic_product_price_dev.run() + assert result + == dynamic_product_price_dev.ExampleResult( + product_definitions: 2, + price_changes: 1, + accepted_orders: 7, + rejected_orders: 3, + concurrent_acceptances: 2, + stored_events: 10, + ) + Nil +} diff --git a/examples/invoice_number/.gitignore b/examples/invoice_number/.gitignore new file mode 100644 index 0000000..982745b --- /dev/null +++ b/examples/invoice_number/.gitignore @@ -0,0 +1,7 @@ +*.beam +*.ez +/build +**/build +node_modules +**/node_modules +erl_crash.dump diff --git a/examples/invoice_number/dev/invoice_number_dev.gleam b/examples/invoice_number/dev/invoice_number_dev.gleam new file mode 100644 index 0000000..f54e51d --- /dev/null +++ b/examples/invoice_number/dev/invoice_number_dev.gleam @@ -0,0 +1,258 @@ +import factos +import factos/factos_pog +import gleam/erlang/application +import gleam/erlang/process +import gleam/int +import gleam/io +import gleam/list +import gleam/option +import gleam/otp/actor +import gleam/string +import invoice_number +import pog +import simplifile +import testcontainer +import testcontainer/error as testcontainer_error +import testcontainer_formulas/postgres +import youid/uuid + +pub type ExampleResult { + ExampleResult( + sequential_numbers: List(Int), + concurrent_numbers: List(Int), + stored_numbers: List(Int), + ) +} + +type WorkerMessage { + WorkerReady(worker: String, release: process.Subject(Nil)) + WorkerFinished( + worker: String, + result: Result( + factos_pog.Dispatch(invoice_number.Event), + factos_pog.Error(Nil), + ), + ) +} + +pub fn run() -> Result(ExampleResult, testcontainer_error.Error) { + use postgres_container <- testcontainer.with_formula( + postgres.new() |> postgres.formula(), + ) + let #(pool_pid, connection) = start_connection(postgres_container) + install_event_store(connection) + let dispatcher = configured_dispatcher() + + let assert Ok(first) = + invoice_number.dispatch( + connection, + dispatcher, + invoice_number.CreateInvoice( + invoice_id: "i1", + invoice_data: invoice_number.InvoiceData(reference: "first"), + ), + uuid.v4_string, + ) + let assert Ok(second) = + invoice_number.dispatch( + connection, + dispatcher, + invoice_number.CreateInvoice( + invoice_id: "i2", + invoice_data: invoice_number.InvoiceData(reference: "second"), + ), + uuid.v4_string, + ) + let sequential_numbers = [ + dispatch_invoice_number(first), + dispatch_invoice_number(second), + ] + assert sequential_numbers == [1, 2] + + let concurrent_numbers = + run_concurrent_invoices(connection, dispatcher) + |> list.map(fn(result) { + let assert Ok(dispatch) = result + dispatch_invoice_number(dispatch) + }) + |> list.sort(by: int.compare) + assert concurrent_numbers == [3, 4] + + let assert Ok(events) = + factos_pog.read_events_after( + connection, + query: factos.AllEvents, + after: factos.NoPosition, + limit: 100, + codec: invoice_number.codec(), + ) + let stored_numbers = + events + |> list.map(fn(recorded) { + let invoice_number.InvoiceCreated(invoice_number:, invoice_data: _) = + recorded.event + invoice_number + }) + |> list.sort(by: int.compare) + assert stored_numbers == [1, 2, 3, 4] + assert has_reference(events, "first") + assert has_reference(events, "second") + assert has_reference(events, "concurrent-a") + assert has_reference(events, "concurrent-b") + + process.send_exit(pool_pid) + process.sleep(100) + Ok(ExampleResult(sequential_numbers:, concurrent_numbers:, stored_numbers:)) +} + +pub fn main() -> Nil { + let assert Ok(ExampleResult( + sequential_numbers: [1, 2], + concurrent_numbers: [3, 4], + stored_numbers: [1, 2, 3, 4], + )) = run() + io.println("first invoice number: 1") + io.println("second invoice number: 2") + io.println("concurrent invoice numbers: 3, 4") + io.println("persisted sequence: 1, 2, 3, 4") +} + +fn configured_dispatcher() -> factos_pog.Dispatcher(invoice_number.Event) { + let assert Ok(dispatcher) = invoice_number.dispatcher() + dispatcher +} + +fn dispatch_invoice_number( + dispatch: factos_pog.Dispatch(invoice_number.Event), +) -> Int { + let assert [recorded] = dispatch.events + let invoice_number.InvoiceCreated(invoice_number:, invoice_data: _) = + recorded.event + invoice_number +} + +fn run_concurrent_invoices( + connection: pog.Connection, + dispatcher: factos_pog.Dispatcher(invoice_number.Event), +) -> List( + Result(factos_pog.Dispatch(invoice_number.Event), factos_pog.Error(Nil)), +) { + let messages = process.new_subject() + start_worker( + connection, + dispatcher:, + messages:, + worker: "first", + invoice_id: "i3", + reference: "concurrent-a", + ) + start_worker( + connection, + dispatcher:, + messages:, + worker: "second", + invoice_id: "i4", + reference: "concurrent-b", + ) + + let first_release = receive_worker_ready(messages) + let second_release = receive_worker_ready(messages) + process.send(first_release, Nil) + process.send(second_release, Nil) + [ + receive_worker_finished(messages), + receive_worker_finished(messages), + ] +} + +fn start_worker( + connection: pog.Connection, + dispatcher dispatcher: factos_pog.Dispatcher(invoice_number.Event), + messages messages: process.Subject(WorkerMessage), + worker worker: String, + invoice_id invoice_id: String, + reference reference: String, +) -> process.Pid { + process.spawn(fn() { + let release = process.new_subject() + process.send(messages, WorkerReady(worker:, release:)) + let assert Ok(Nil) = process.receive(release, within: 10_000) + let result = + invoice_number.dispatch( + connection, + dispatcher, + invoice_number.CreateInvoice( + invoice_id:, + invoice_data: invoice_number.InvoiceData(reference:), + ), + uuid.v4_string, + ) + process.send(messages, WorkerFinished(worker:, result:)) + }) +} + +fn receive_worker_ready( + messages: process.Subject(WorkerMessage), +) -> process.Subject(Nil) { + let assert Ok(message) = process.receive(messages, within: 10_000) + let assert WorkerReady(worker: _, release:) = message + release +} + +fn receive_worker_finished( + messages: process.Subject(WorkerMessage), +) -> Result(factos_pog.Dispatch(invoice_number.Event), factos_pog.Error(Nil)) { + let assert Ok(message) = process.receive(messages, within: 10_000) + let assert WorkerFinished(worker: _, result:) = message + result +} + +fn has_reference( + events: List(factos.Recorded(invoice_number.Event)), + reference: String, +) -> Bool { + list.any(events, fn(recorded) { + let invoice_number.InvoiceCreated(invoice_number: _, invoice_data:) = + recorded.event + let invoice_number.InvoiceData(reference: event_reference) = invoice_data + event_reference == reference + }) +} + +fn start_connection( + postgres_container: postgres.PostgresContainer, +) -> #(process.Pid, pog.Connection) { + let postgres.PostgresContainer(host:, port:, database:, username:, ..) = + postgres_container + let pool_name = process.new_name("invoice_number") + let config = + pog.default_config(pool_name) + |> pog.host(host) + |> pog.port(port) + |> pog.database(database) + |> pog.user(username) + |> pog.password(option.Some("postgres")) + |> pog.ssl(pog.SslDisabled) + + let assert Ok(actor.Started(pid:, ..)) = pog.start(config) + process.sleep(100) + #(pid, pog.named_connection(pool_name)) +} + +fn install_event_store(connection: pog.Connection) -> Nil { + let assert Ok(priv_directory) = application.priv_directory("factos_pog") + let assert Ok(sql) = + simplifile.read( + priv_directory <> "/dbmate/20260703000100_factos_pog_event_store.sql", + ) + let assert [up, ..] = string.split(sql, "-- migrate:down") + + up + |> string.split(";") + |> list.map(string.trim) + |> list.filter(fn(statement) { statement != "" }) + |> list.each(fn(statement) { + let assert Ok(_) = pog.query(statement) |> pog.execute(on: connection) + Nil + }) +} diff --git a/examples/invoice_number/gleam.toml b/examples/invoice_number/gleam.toml new file mode 100644 index 0000000..137cfcd --- /dev/null +++ b/examples/invoice_number/gleam.toml @@ -0,0 +1,18 @@ +name = "invoice_number" +version = "1.0.0" + +[dependencies] +factos = { path = "../.." } +factos_pog = { path = "../../backends/factos_pog" } +gleam_json = ">= 3.1.0 and < 4.0.0" +gleam_stdlib = ">= 1.0.0 and < 2.0.0" +pog = { git = "https://github.com/foxfriends/pog.git", ref = "919fd6ac96095ea11fa7c940b17eaece49cc5993" } + +[dev_dependencies] +gleam_erlang = ">= 1.0.0 and < 2.0.0" +gleam_otp = ">= 1.2.0 and < 2.0.0" +gleeunit = ">= 1.0.0 and < 2.0.0" +simplifile = ">= 2.5.0 and < 3.0.0" +testcontainer = ">= 1.0.2 and < 2.0.0" +testcontainer_formulas = ">= 1.0.0 and < 2.0.0" +youid = ">= 1.5.4 and < 2.0.0" diff --git a/examples/invoice_number/manifest.toml b/examples/invoice_number/manifest.toml new file mode 100644 index 0000000..7f06b78 --- /dev/null +++ b/examples/invoice_number/manifest.toml @@ -0,0 +1,46 @@ +# Do not manually edit this file, it is managed by Gleam. +# +# This file locks the dependency versions used, to make your build +# deterministic and to prevent unexpected versions from being included +# in your application. +# +# You should check this file into your source control repository. + +packages = [ + { name = "backoff", version = "1.1.6", build_tools = ["rebar3"], requirements = [], otp_app = "backoff", source = "hex", outer_checksum = "CF0CFFF8995FB20562F822E5CC47D8CCF664C5ECDC26A684CBE85C225F9D7C39" }, + { name = "cowl", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "cowl", source = "hex", outer_checksum = "7849E7C789D7228243A4253138FC883720A0BB44AEF406102328CADC64C3CA2B" }, + { name = "envie", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envie", source = "hex", outer_checksum = "E7EBA39310F32A40BF3EDDD7CD9C7A2BC289909983D357411C22873415BC322A" }, + { name = "exception", version = "2.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "6BDEA95248093599391C3B5DF1835C5C6A86C353C2F99CE539B450E3432FE117" }, + { name = "factos", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], source = "local", path = "../.." }, + { name = "factos_pog", version = "2.0.0", build_tools = ["gleam"], requirements = ["factos", "gleam_erlang", "gleam_otp", "gleam_stdlib", "gleam_time", "pog"], source = "local", path = "../../backends/factos_pog" }, + { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, + { name = "gleam_crypto", version = "1.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "2DE9E4EF53CF6FEE049D4F765731F7178F7A11AEFAE00EEE63BF7536B354AD3F" }, + { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, + { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, + { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, + { name = "gleam_stdlib", version = "1.0.5", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "CEE5B6C076A85B45F60C585F4316C63EC8B7127C119D5738C3958A9C4D50404E" }, + { name = "gleam_time", version = "1.8.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "533D8723774D61AD4998324F5DD1DABDCDBFABAFB9E87CB5D03C6955448FC97D" }, + { name = "gleeunit", version = "1.11.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "EC31ABA74256AEA531EDF8169931D775BBB384FED0A8A1BDC4DD9354E3E21826" }, + { name = "opentelemetry_api", version = "1.5.0", build_tools = ["rebar3", "mix"], requirements = [], otp_app = "opentelemetry_api", source = "hex", outer_checksum = "F53EC8A1337AE4A487D43AC89DA4BD3A3C99DDF576655D071DEED8B56A2D5DDA" }, + { name = "pg_types", version = "0.6.0", build_tools = ["rebar3"], requirements = [], otp_app = "pg_types", source = "hex", outer_checksum = "9949A4849DD13408FA249AB7B745E0D2DFDB9532AEE2B9722326E33CD082A778" }, + { name = "pgo", version = "0.20.0", build_tools = ["rebar3"], requirements = ["backoff", "opentelemetry_api", "pg_types"], otp_app = "pgo", source = "hex", outer_checksum = "2F11E6649CEB38E569EF56B16BE1D04874AE5B11A02867080A2817CE423C683B" }, + { name = "pog", version = "4.1.0", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_otp", "gleam_stdlib", "gleam_time", "pgo"], source = "git", repo = "https://github.com/foxfriends/pog.git", commit = "919fd6ac96095ea11fa7c940b17eaece49cc5993" }, + { name = "simplifile", version = "2.7.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "A2727627B063E87351934C7F7F008F2D1FDB16F6DE0B8C79F9E46459CFC9C164" }, + { name = "testcontainer", version = "1.0.2", build_tools = ["gleam"], requirements = ["cowl", "envie", "gleam_erlang", "gleam_json", "gleam_stdlib"], otp_app = "testcontainer", source = "hex", outer_checksum = "784768485ED2380AA543A0CC3F06F7A368B0DD209102C57E800E0A87E1D2FC81" }, + { name = "testcontainer_formulas", version = "1.0.0", build_tools = ["gleam"], requirements = ["cowl", "gleam_stdlib", "testcontainer"], otp_app = "testcontainer_formulas", source = "hex", outer_checksum = "F9A86A2F8400A0C72FE98F56EF5B3FD1CE10F0A63D968A1C57EA0087A3E5802B" }, + { name = "youid", version = "1.6.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_stdlib", "gleam_time"], otp_app = "youid", source = "hex", outer_checksum = "7A3ABA44B1B38BC2BDCB5474C5317AA372BE58DFBC649815EE08B03526DDA18D" }, +] + +[requirements] +factos = { path = "../.." } +factos_pog = { path = "../../backends/factos_pog" } +gleam_erlang = { version = ">= 1.0.0 and < 2.0.0" } +gleam_json = { version = ">= 3.1.0 and < 4.0.0" } +gleam_otp = { version = ">= 1.2.0 and < 2.0.0" } +gleam_stdlib = { version = ">= 1.0.0 and < 2.0.0" } +gleeunit = { version = ">= 1.0.0 and < 2.0.0" } +pog = { git = "https://github.com/foxfriends/pog.git", ref = "919fd6ac96095ea11fa7c940b17eaece49cc5993" } +simplifile = { version = ">= 2.5.0 and < 3.0.0" } +testcontainer = { version = ">= 1.0.2 and < 2.0.0" } +testcontainer_formulas = { version = ">= 1.0.0 and < 2.0.0" } +youid = { version = ">= 1.5.4 and < 2.0.0" } diff --git a/examples/invoice_number/src/invoice_number.gleam b/examples/invoice_number/src/invoice_number.gleam new file mode 100644 index 0000000..eaa4b40 --- /dev/null +++ b/examples/invoice_number/src/invoice_number.gleam @@ -0,0 +1,138 @@ +//// Create a monotonic, gapless invoice-number sequence with Dynamic +//// Consistency Boundaries. +//// +//// This implements the example at +//// https://dcb.events/examples/invoice-number/ using Factos and PostgreSQL. + +import factos +import factos/factos_pog +import gleam/bit_array +import gleam/dynamic/decode +import gleam/int +import gleam/json +import gleam/result +import pog + +pub type InvoiceData { + InvoiceData(reference: String) +} + +pub type Event { + InvoiceCreated(invoice_number: Int, invoice_data: InvoiceData) +} + +pub type Command { + CreateInvoice(invoice_id: String, invoice_data: InvoiceData) +} + +type State { + CreatingInvoice(next_invoice_number: Int) +} + +fn initial(command: Command) -> State { + case command { + CreateInvoice(invoice_id: _, invoice_data: _) -> + CreatingInvoice(next_invoice_number: 1) + } +} + +fn decide(state: State, command: Command) -> Result(List(Event), Nil) { + case state, command { + CreatingInvoice(next_invoice_number:), + CreateInvoice(invoice_id: _, invoice_data:) + -> Ok([InvoiceCreated(invoice_number: next_invoice_number, invoice_data:)]) + } +} + +fn evolve(state: State, event: Event) -> State { + case state, event { + CreatingInvoice(next_invoice_number: _), + InvoiceCreated(invoice_number:, invoice_data: _) + -> CreatingInvoice(next_invoice_number: invoice_number + 1) + } +} + +pub fn codec() -> factos_pog.EventCodec(Event) { + factos_pog.codec(encode: encode_event, decode: decode_event) +} + +fn encode_event(event: Event) -> factos_pog.Proposed(Event) { + let InvoiceCreated(invoice_number:, invoice_data:) = event + let InvoiceData(reference:) = invoice_data + let data = + json.object([ + #("invoice_number", json.int(invoice_number)), + #("invoice_data", json.object([#("reference", json.string(reference))])), + ]) + |> json.to_string + |> bit_array.from_string + + factos_pog.new_proposed( + type_: factos.event_type("InvoiceCreated"), + version: 1, + data:, + ) + |> factos_pog.with_tags(tags: [ + factos.tag("invoice:" <> int.to_string(invoice_number)), + ]) +} + +fn decode_event( + stored: factos_pog.StoredEvent, +) -> Result(factos.Decoded(Event), factos_pog.DecodeError) { + case factos.event_type_name(stored.type_), stored.version { + "InvoiceCreated", 1 -> { + use event <- result.try( + json.parse_bits(from: stored.data, using: event_decoder()) + |> result.replace_error(factos_pog.InvalidData), + ) + factos_pog.decoded_event(stored, event:) + } + _, _ -> Error(factos_pog.UnknownEvent) + } +} + +fn event_decoder() -> decode.Decoder(Event) { + use invoice_number <- decode.field("invoice_number", decode.int) + use invoice_data <- decode.field("invoice_data", invoice_data_decoder()) + decode.success(InvoiceCreated(invoice_number:, invoice_data:)) +} + +fn invoice_data_decoder() -> decode.Decoder(InvoiceData) { + use reference <- decode.field("reference", decode.string) + decode.success(InvoiceData(reference:)) +} + +pub fn dispatcher() -> Result( + factos_pog.Dispatcher(Event), + factos_pog.DispatcherConfigurationError, +) { + factos_pog.dispatcher(codec: codec(), subscriptions: []) +} + +pub fn dispatch( + connection: pog.Connection, + dispatcher: factos_pog.Dispatcher(Event), + command: Command, + event_id: fn() -> String, +) -> Result(factos_pog.Dispatch(Event), factos_pog.Error(Nil)) { + factos_pog.new_dispatch( + connection:, + stream: stream(command), + decider: factos.decider(initial: initial(command), decide:, evolve:), + dispatcher:, + ) + |> factos_pog.with_query(query(command)) + |> factos_pog.dispatch(command, event_id:) +} + +fn query(_command: Command) -> factos.Query { + factos.query([ + factos.query_item(types: [factos.event_type("InvoiceCreated")], tags: []), + ]) +} + +fn stream(command: Command) -> String { + let CreateInvoice(invoice_id:, invoice_data: _) = command + "invoice-" <> invoice_id +} diff --git a/examples/invoice_number/test/invoice_number_test.gleam b/examples/invoice_number/test/invoice_number_test.gleam new file mode 100644 index 0000000..2c51b95 --- /dev/null +++ b/examples/invoice_number/test/invoice_number_test.gleam @@ -0,0 +1,22 @@ +import gleeunit +import invoice_number_dev + +pub type Timeout(a) { + Timeout(time: Int, function: fn() -> a) +} + +pub fn main() -> Nil { + gleeunit.main() +} + +pub fn invoice_number_example_test_() -> Timeout(Nil) { + use <- Timeout(120) + let assert Ok(result) = invoice_number_dev.run() + assert result + == invoice_number_dev.ExampleResult( + sequential_numbers: [1, 2], + concurrent_numbers: [3, 4], + stored_numbers: [1, 2, 3, 4], + ) + Nil +} diff --git a/examples/opt_in_token/.gitignore b/examples/opt_in_token/.gitignore new file mode 100644 index 0000000..982745b --- /dev/null +++ b/examples/opt_in_token/.gitignore @@ -0,0 +1,7 @@ +*.beam +*.ez +/build +**/build +node_modules +**/node_modules +erl_crash.dump diff --git a/examples/opt_in_token/dev/opt_in_token_dev.gleam b/examples/opt_in_token/dev/opt_in_token_dev.gleam new file mode 100644 index 0000000..32abd5a --- /dev/null +++ b/examples/opt_in_token/dev/opt_in_token_dev.gleam @@ -0,0 +1,459 @@ +import factos +import factos/factos_pog +import gleam/erlang/application +import gleam/erlang/process +import gleam/io +import gleam/list +import gleam/option +import gleam/otp/actor +import gleam/string +import opt_in_token +import pog +import simplifile +import testcontainer +import testcontainer/error as testcontainer_error +import testcontainer_formulas/postgres +import youid/uuid + +pub type ExampleResult { + ExampleResult( + initiated_sign_ups: Int, + confirmed_sign_ups: Int, + sequential_rejections: Int, + concurrent_acceptances: Int, + concurrent_rejections: Int, + stored_events: Int, + ) +} + +type WorkerMessage { + WorkerReady(worker: String, release: process.Subject(Nil)) + WorkerFinished( + worker: String, + result: Result( + factos_pog.Dispatch(opt_in_token.Event), + factos_pog.Error(opt_in_token.Error), + ), + ) +} + +pub fn run() -> Result(ExampleResult, testcontainer_error.Error) { + use postgres_container <- testcontainer.with_formula( + postgres.new() |> postgres.formula(), + ) + let #(pool_pid, connection) = start_connection(postgres_container) + install_event_store(connection) + let dispatcher = configured_dispatcher() + + require_domain_error( + opt_in_token.dispatch( + connection, + dispatcher, + opt_in_token.ConfirmSignUp( + confirmation_id: "missing", + email_address: "john.doe@example.com", + otp: "000000", + current_minute: 0, + ), + uuid.v4_string, + ), + expected: opt_in_token.NoPendingSignUp, + ) + + require_initiation( + connection, + dispatcher, + sign_up_id: "s1", + email_address: "john.doe@example.com", + otp: "111111", + name: "John Doe", + initiated_minute: 0, + ) + require_domain_error( + opt_in_token.dispatch( + connection, + dispatcher, + opt_in_token.ConfirmSignUp( + confirmation_id: "wrong-email", + email_address: "jane.doe@example.com", + otp: "111111", + current_minute: 1, + ), + uuid.v4_string, + ), + expected: opt_in_token.NoPendingSignUp, + ) + + require_initiation( + connection, + dispatcher, + sign_up_id: "s2", + email_address: "john.doe@example.com", + otp: "222222", + name: "John Doe", + initiated_minute: 0, + ) + let assert Ok(used_confirmation) = + opt_in_token.dispatch( + connection, + dispatcher, + opt_in_token.ConfirmSignUp( + confirmation_id: "used-first", + email_address: "john.doe@example.com", + otp: "222222", + current_minute: 1, + ), + uuid.v4_string, + ) + assert_confirmation( + used_confirmation, + email_address: "john.doe@example.com", + otp: "222222", + name: "John Doe", + ) + require_domain_error( + opt_in_token.dispatch( + connection, + dispatcher, + opt_in_token.ConfirmSignUp( + confirmation_id: "used-second", + email_address: "john.doe@example.com", + otp: "222222", + current_minute: 2, + ), + uuid.v4_string, + ), + expected: opt_in_token.OtpAlreadyUsed, + ) + + require_initiation( + connection, + dispatcher, + sign_up_id: "s4", + email_address: "john.doe@example.com", + otp: "444444", + name: "John Doe", + initiated_minute: 0, + ) + let assert Ok(boundary_confirmation) = + opt_in_token.dispatch( + connection, + dispatcher, + opt_in_token.ConfirmSignUp( + confirmation_id: "boundary", + email_address: "john.doe@example.com", + otp: "444444", + current_minute: 60, + ), + uuid.v4_string, + ) + assert_confirmation( + boundary_confirmation, + email_address: "john.doe@example.com", + otp: "444444", + name: "John Doe", + ) + + require_initiation( + connection, + dispatcher, + sign_up_id: "s3", + email_address: "john.doe@example.com", + otp: "333333", + name: "John Doe", + initiated_minute: 0, + ) + require_domain_error( + opt_in_token.dispatch( + connection, + dispatcher, + opt_in_token.ConfirmSignUp( + confirmation_id: "expired", + email_address: "john.doe@example.com", + otp: "333333", + current_minute: 61, + ), + uuid.v4_string, + ), + expected: opt_in_token.OtpExpired, + ) + + require_initiation( + connection, + dispatcher, + sign_up_id: "s5", + email_address: "race@example.com", + otp: "555555", + name: "Race Winner", + initiated_minute: 0, + ) + let concurrent_results = run_concurrent_confirmation(connection, dispatcher) + let concurrent_acceptances = + list.count(concurrent_results, where: is_accepted) + let concurrent_rejections = + list.count(concurrent_results, where: is_already_used) + assert concurrent_acceptances == 1 + assert concurrent_rejections == 1 + + let assert Ok(events) = + factos_pog.read_events_after( + connection, + query: factos.AllEvents, + after: factos.NoPosition, + limit: 100, + codec: opt_in_token.codec(), + ) + assert list.count(events, where: is_initiated) == 5 + assert list.count(events, where: is_confirmed) == 3 + assert list.length(events) == 8 + + process.send_exit(pool_pid) + process.sleep(100) + Ok(ExampleResult( + initiated_sign_ups: 5, + confirmed_sign_ups: 3, + sequential_rejections: 4, + concurrent_acceptances:, + concurrent_rejections:, + stored_events: list.length(events), + )) +} + +pub fn main() -> Nil { + let assert Ok(ExampleResult( + initiated_sign_ups: 5, + confirmed_sign_ups: 3, + sequential_rejections: 4, + concurrent_acceptances: 1, + concurrent_rejections: 1, + stored_events: 8, + )) = run() + io.println("missing or mismatched OTP: rejected") + io.println("valid OTP: accepted") + io.println("used OTP: rejected") + io.println("OTP at 60 minutes: accepted") + io.println("OTP at 61 minutes: expired") + io.println("concurrent confirmation: 1 accepted, 1 rejected") + io.println("persisted events: 8") +} + +fn configured_dispatcher() -> factos_pog.Dispatcher(opt_in_token.Event) { + let assert Ok(dispatcher) = opt_in_token.dispatcher() + dispatcher +} + +fn require_initiation( + connection: pog.Connection, + dispatcher: factos_pog.Dispatcher(opt_in_token.Event), + sign_up_id sign_up_id: String, + email_address email_address: String, + otp otp: String, + name name: String, + initiated_minute initiated_minute: Int, +) -> Nil { + let assert Ok(_) = + opt_in_token.dispatch( + connection, + dispatcher, + opt_in_token.InitiateSignUp( + sign_up_id:, + email_address:, + otp:, + name:, + initiated_minute:, + ), + uuid.v4_string, + ) + Nil +} + +fn require_domain_error( + result: Result( + factos_pog.Dispatch(opt_in_token.Event), + factos_pog.Error(opt_in_token.Error), + ), + expected expected: opt_in_token.Error, +) -> Nil { + let assert Error(factos_pog.DomainError(actual)) = result + assert actual == expected + Nil +} + +fn assert_confirmation( + dispatch: factos_pog.Dispatch(opt_in_token.Event), + email_address email_address: String, + otp otp: String, + name name: String, +) -> Nil { + let assert [recorded] = dispatch.events + assert recorded.event + == opt_in_token.SignUpConfirmed(email_address:, otp:, name:) + Nil +} + +fn run_concurrent_confirmation( + connection: pog.Connection, + dispatcher: factos_pog.Dispatcher(opt_in_token.Event), +) -> List( + Result( + factos_pog.Dispatch(opt_in_token.Event), + factos_pog.Error(opt_in_token.Error), + ), +) { + let messages = process.new_subject() + start_worker( + connection, + dispatcher:, + messages:, + worker: "first", + confirmation_id: "race-a", + ) + start_worker( + connection, + dispatcher:, + messages:, + worker: "second", + confirmation_id: "race-b", + ) + + let first_release = receive_worker_ready(messages) + let second_release = receive_worker_ready(messages) + process.send(first_release, Nil) + process.send(second_release, Nil) + [ + receive_worker_finished(messages), + receive_worker_finished(messages), + ] +} + +fn start_worker( + connection: pog.Connection, + dispatcher dispatcher: factos_pog.Dispatcher(opt_in_token.Event), + messages messages: process.Subject(WorkerMessage), + worker worker: String, + confirmation_id confirmation_id: String, +) -> process.Pid { + process.spawn(fn() { + let release = process.new_subject() + process.send(messages, WorkerReady(worker:, release:)) + let assert Ok(Nil) = process.receive(release, within: 10_000) + let result = + opt_in_token.dispatch( + connection, + dispatcher, + opt_in_token.ConfirmSignUp( + confirmation_id:, + email_address: "race@example.com", + otp: "555555", + current_minute: 1, + ), + uuid.v4_string, + ) + process.send(messages, WorkerFinished(worker:, result:)) + }) +} + +fn receive_worker_ready( + messages: process.Subject(WorkerMessage), +) -> process.Subject(Nil) { + let assert Ok(message) = process.receive(messages, within: 10_000) + let assert WorkerReady(worker: _, release:) = message + release +} + +fn receive_worker_finished( + messages: process.Subject(WorkerMessage), +) -> Result( + factos_pog.Dispatch(opt_in_token.Event), + factos_pog.Error(opt_in_token.Error), +) { + let assert Ok(message) = process.receive(messages, within: 10_000) + let assert WorkerFinished(worker: _, result:) = message + result +} + +fn is_accepted( + result: Result( + factos_pog.Dispatch(opt_in_token.Event), + factos_pog.Error(opt_in_token.Error), + ), +) -> Bool { + case result { + Ok(_) -> True + Error(_) -> False + } +} + +fn is_already_used( + result: Result( + factos_pog.Dispatch(opt_in_token.Event), + factos_pog.Error(opt_in_token.Error), + ), +) -> Bool { + case result { + Error(factos_pog.DomainError(opt_in_token.OtpAlreadyUsed)) -> True + Ok(_) | Error(_) -> False + } +} + +fn is_initiated(recorded: factos.Recorded(opt_in_token.Event)) -> Bool { + case recorded.event { + opt_in_token.SignUpInitiated( + email_address: _, + otp: _, + name: _, + initiated_minute: _, + ) -> True + opt_in_token.SignUpConfirmed(email_address: _, otp: _, name: _) -> False + } +} + +fn is_confirmed(recorded: factos.Recorded(opt_in_token.Event)) -> Bool { + case recorded.event { + opt_in_token.SignUpConfirmed(email_address: _, otp: _, name: _) -> True + opt_in_token.SignUpInitiated( + email_address: _, + otp: _, + name: _, + initiated_minute: _, + ) -> False + } +} + +fn start_connection( + postgres_container: postgres.PostgresContainer, +) -> #(process.Pid, pog.Connection) { + let postgres.PostgresContainer(host:, port:, database:, username:, ..) = + postgres_container + let pool_name = process.new_name("opt_in_token") + let config = + pog.default_config(pool_name) + |> pog.host(host) + |> pog.port(port) + |> pog.database(database) + |> pog.user(username) + |> pog.password(option.Some("postgres")) + |> pog.ssl(pog.SslDisabled) + + let assert Ok(actor.Started(pid:, ..)) = pog.start(config) + process.sleep(100) + #(pid, pog.named_connection(pool_name)) +} + +fn install_event_store(connection: pog.Connection) -> Nil { + let assert Ok(priv_directory) = application.priv_directory("factos_pog") + let assert Ok(sql) = + simplifile.read( + priv_directory <> "/dbmate/20260703000100_factos_pog_event_store.sql", + ) + let assert [up, ..] = string.split(sql, "-- migrate:down") + + up + |> string.split(";") + |> list.map(string.trim) + |> list.filter(fn(statement) { statement != "" }) + |> list.each(fn(statement) { + let assert Ok(_) = pog.query(statement) |> pog.execute(on: connection) + Nil + }) +} diff --git a/examples/opt_in_token/gleam.toml b/examples/opt_in_token/gleam.toml new file mode 100644 index 0000000..a349884 --- /dev/null +++ b/examples/opt_in_token/gleam.toml @@ -0,0 +1,18 @@ +name = "opt_in_token" +version = "1.0.0" + +[dependencies] +factos = { path = "../.." } +factos_pog = { path = "../../backends/factos_pog" } +gleam_json = ">= 3.1.0 and < 4.0.0" +gleam_stdlib = ">= 1.0.0 and < 2.0.0" +pog = { git = "https://github.com/foxfriends/pog.git", ref = "919fd6ac96095ea11fa7c940b17eaece49cc5993" } + +[dev_dependencies] +gleam_erlang = ">= 1.0.0 and < 2.0.0" +gleam_otp = ">= 1.2.0 and < 2.0.0" +gleeunit = ">= 1.0.0 and < 2.0.0" +simplifile = ">= 2.5.0 and < 3.0.0" +testcontainer = ">= 1.0.2 and < 2.0.0" +testcontainer_formulas = ">= 1.0.0 and < 2.0.0" +youid = ">= 1.5.4 and < 2.0.0" diff --git a/examples/opt_in_token/manifest.toml b/examples/opt_in_token/manifest.toml new file mode 100644 index 0000000..7f06b78 --- /dev/null +++ b/examples/opt_in_token/manifest.toml @@ -0,0 +1,46 @@ +# Do not manually edit this file, it is managed by Gleam. +# +# This file locks the dependency versions used, to make your build +# deterministic and to prevent unexpected versions from being included +# in your application. +# +# You should check this file into your source control repository. + +packages = [ + { name = "backoff", version = "1.1.6", build_tools = ["rebar3"], requirements = [], otp_app = "backoff", source = "hex", outer_checksum = "CF0CFFF8995FB20562F822E5CC47D8CCF664C5ECDC26A684CBE85C225F9D7C39" }, + { name = "cowl", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "cowl", source = "hex", outer_checksum = "7849E7C789D7228243A4253138FC883720A0BB44AEF406102328CADC64C3CA2B" }, + { name = "envie", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envie", source = "hex", outer_checksum = "E7EBA39310F32A40BF3EDDD7CD9C7A2BC289909983D357411C22873415BC322A" }, + { name = "exception", version = "2.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "6BDEA95248093599391C3B5DF1835C5C6A86C353C2F99CE539B450E3432FE117" }, + { name = "factos", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], source = "local", path = "../.." }, + { name = "factos_pog", version = "2.0.0", build_tools = ["gleam"], requirements = ["factos", "gleam_erlang", "gleam_otp", "gleam_stdlib", "gleam_time", "pog"], source = "local", path = "../../backends/factos_pog" }, + { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, + { name = "gleam_crypto", version = "1.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "2DE9E4EF53CF6FEE049D4F765731F7178F7A11AEFAE00EEE63BF7536B354AD3F" }, + { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, + { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, + { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, + { name = "gleam_stdlib", version = "1.0.5", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "CEE5B6C076A85B45F60C585F4316C63EC8B7127C119D5738C3958A9C4D50404E" }, + { name = "gleam_time", version = "1.8.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "533D8723774D61AD4998324F5DD1DABDCDBFABAFB9E87CB5D03C6955448FC97D" }, + { name = "gleeunit", version = "1.11.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "EC31ABA74256AEA531EDF8169931D775BBB384FED0A8A1BDC4DD9354E3E21826" }, + { name = "opentelemetry_api", version = "1.5.0", build_tools = ["rebar3", "mix"], requirements = [], otp_app = "opentelemetry_api", source = "hex", outer_checksum = "F53EC8A1337AE4A487D43AC89DA4BD3A3C99DDF576655D071DEED8B56A2D5DDA" }, + { name = "pg_types", version = "0.6.0", build_tools = ["rebar3"], requirements = [], otp_app = "pg_types", source = "hex", outer_checksum = "9949A4849DD13408FA249AB7B745E0D2DFDB9532AEE2B9722326E33CD082A778" }, + { name = "pgo", version = "0.20.0", build_tools = ["rebar3"], requirements = ["backoff", "opentelemetry_api", "pg_types"], otp_app = "pgo", source = "hex", outer_checksum = "2F11E6649CEB38E569EF56B16BE1D04874AE5B11A02867080A2817CE423C683B" }, + { name = "pog", version = "4.1.0", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_otp", "gleam_stdlib", "gleam_time", "pgo"], source = "git", repo = "https://github.com/foxfriends/pog.git", commit = "919fd6ac96095ea11fa7c940b17eaece49cc5993" }, + { name = "simplifile", version = "2.7.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "A2727627B063E87351934C7F7F008F2D1FDB16F6DE0B8C79F9E46459CFC9C164" }, + { name = "testcontainer", version = "1.0.2", build_tools = ["gleam"], requirements = ["cowl", "envie", "gleam_erlang", "gleam_json", "gleam_stdlib"], otp_app = "testcontainer", source = "hex", outer_checksum = "784768485ED2380AA543A0CC3F06F7A368B0DD209102C57E800E0A87E1D2FC81" }, + { name = "testcontainer_formulas", version = "1.0.0", build_tools = ["gleam"], requirements = ["cowl", "gleam_stdlib", "testcontainer"], otp_app = "testcontainer_formulas", source = "hex", outer_checksum = "F9A86A2F8400A0C72FE98F56EF5B3FD1CE10F0A63D968A1C57EA0087A3E5802B" }, + { name = "youid", version = "1.6.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_stdlib", "gleam_time"], otp_app = "youid", source = "hex", outer_checksum = "7A3ABA44B1B38BC2BDCB5474C5317AA372BE58DFBC649815EE08B03526DDA18D" }, +] + +[requirements] +factos = { path = "../.." } +factos_pog = { path = "../../backends/factos_pog" } +gleam_erlang = { version = ">= 1.0.0 and < 2.0.0" } +gleam_json = { version = ">= 3.1.0 and < 4.0.0" } +gleam_otp = { version = ">= 1.2.0 and < 2.0.0" } +gleam_stdlib = { version = ">= 1.0.0 and < 2.0.0" } +gleeunit = { version = ">= 1.0.0 and < 2.0.0" } +pog = { git = "https://github.com/foxfriends/pog.git", ref = "919fd6ac96095ea11fa7c940b17eaece49cc5993" } +simplifile = { version = ">= 2.5.0 and < 3.0.0" } +testcontainer = { version = ">= 1.0.2 and < 2.0.0" } +testcontainer_formulas = { version = ">= 1.0.0 and < 2.0.0" } +youid = { version = ">= 1.5.4 and < 2.0.0" } diff --git a/examples/opt_in_token/src/opt_in_token.gleam b/examples/opt_in_token/src/opt_in_token.gleam new file mode 100644 index 0000000..e21809a --- /dev/null +++ b/examples/opt_in_token/src/opt_in_token.gleam @@ -0,0 +1,338 @@ +//// Confirm expiring one-time sign-up tokens with Dynamic Consistency +//// Boundaries. +//// +//// This implements the example at +//// https://dcb.events/examples/opt-in-token/ using Factos and PostgreSQL. +//// The source's relative `minutesAgo` metadata is represented by an absolute +//// `initiated_minute`, keeping retrying decisions deterministic. + +import factos +import factos/factos_pog +import gleam/bit_array +import gleam/dynamic/decode +import gleam/int +import gleam/json +import gleam/result +import pog + +const otp_validity_minutes = 60 + +const initiated_minute_key = "initiated_minute" + +pub type Event { + SignUpInitiated( + email_address: String, + otp: String, + name: String, + initiated_minute: Int, + ) + SignUpConfirmed(email_address: String, otp: String, name: String) +} + +pub type Command { + InitiateSignUp( + sign_up_id: String, + email_address: String, + otp: String, + name: String, + initiated_minute: Int, + ) + ConfirmSignUp( + confirmation_id: String, + email_address: String, + otp: String, + current_minute: Int, + ) +} + +pub type Error { + NoPendingSignUp + OtpAlreadyUsed + OtpExpired +} + +type OtpStatus { + OtpUnused + OtpUsed +} + +type ConfirmationState { + NoPending + PendingSignUp(name: String, initiated_minute: Int, status: OtpStatus) +} + +type State { + InitiatingSignUp + ConfirmingSignUp(confirmation: ConfirmationState) +} + +fn initial(command: Command) -> State { + case command { + InitiateSignUp( + sign_up_id: _, + email_address: _, + otp: _, + name: _, + initiated_minute: _, + ) -> InitiatingSignUp + ConfirmSignUp( + confirmation_id: _, + email_address: _, + otp: _, + current_minute: _, + ) -> ConfirmingSignUp(confirmation: NoPending) + } +} + +fn decide(state: State, command: Command) -> Result(List(Event), Error) { + case state, command { + InitiatingSignUp, + InitiateSignUp( + sign_up_id: _, + email_address:, + otp:, + name:, + initiated_minute:, + ) + -> Ok([SignUpInitiated(email_address:, otp:, name:, initiated_minute:)]) + ConfirmingSignUp(confirmation: NoPending), + ConfirmSignUp( + confirmation_id: _, + email_address: _, + otp: _, + current_minute: _, + ) + -> Error(NoPendingSignUp) + ConfirmingSignUp(confirmation: PendingSignUp( + name: _, + initiated_minute: _, + status: OtpUsed, + )), + ConfirmSignUp( + confirmation_id: _, + email_address: _, + otp: _, + current_minute: _, + ) + -> Error(OtpAlreadyUsed) + ConfirmingSignUp(confirmation: PendingSignUp( + name: _, + initiated_minute:, + status: OtpUnused, + )), + ConfirmSignUp( + confirmation_id: _, + email_address: _, + otp: _, + current_minute:, + ) + if current_minute - initiated_minute > otp_validity_minutes + -> Error(OtpExpired) + ConfirmingSignUp(confirmation: PendingSignUp( + name:, + initiated_minute: _, + status: OtpUnused, + )), + ConfirmSignUp(confirmation_id: _, email_address:, otp:, current_minute: _) + -> Ok([SignUpConfirmed(email_address:, otp:, name:)]) + _, _ -> panic as "Command executed for wrong state" + } +} + +fn evolve(state: State, event: Event) -> State { + case state, event { + InitiatingSignUp, + SignUpInitiated(email_address: _, otp: _, name: _, initiated_minute: _) + | InitiatingSignUp, SignUpConfirmed(email_address: _, otp: _, name: _) + -> InitiatingSignUp + ConfirmingSignUp(confirmation: _), + SignUpInitiated(email_address: _, otp: _, name:, initiated_minute:) + -> + ConfirmingSignUp(confirmation: PendingSignUp( + name:, + initiated_minute:, + status: OtpUnused, + )) + ConfirmingSignUp(confirmation: NoPending), + SignUpConfirmed(email_address: _, otp: _, name: _) + -> ConfirmingSignUp(confirmation: NoPending) + ConfirmingSignUp(confirmation: PendingSignUp( + name:, + initiated_minute:, + status: _, + )), + SignUpConfirmed(email_address: _, otp: _, name: _) + -> + ConfirmingSignUp(confirmation: PendingSignUp( + name:, + initiated_minute:, + status: OtpUsed, + )) + } +} + +pub fn codec() -> factos_pog.EventCodec(Event) { + factos_pog.codec(encode: encode_event, decode: decode_event) +} + +fn encode_event(event: Event) -> factos_pog.Proposed(Event) { + case event { + SignUpInitiated(email_address:, otp:, name:, initiated_minute:) -> + proposed_event( + type_: "SignUpInitiated", + data: sign_up_data(email_address, otp, name), + tags: sign_up_tags(email_address, otp), + ) + |> factos_pog.with_metadata( + metadata: factos.metadata([ + #(initiated_minute_key, int.to_string(initiated_minute)), + ]), + ) + SignUpConfirmed(email_address:, otp:, name:) -> + proposed_event( + type_: "SignUpConfirmed", + data: sign_up_data(email_address, otp, name), + tags: sign_up_tags(email_address, otp), + ) + } +} + +fn sign_up_data(email_address: String, otp: String, name: String) -> json.Json { + json.object([ + #("email_address", json.string(email_address)), + #("otp", json.string(otp)), + #("name", json.string(name)), + ]) +} + +fn sign_up_tags(email_address: String, otp: String) -> List(factos.Tag) { + [ + factos.tag("email:" <> email_address), + factos.tag("otp:" <> otp), + ] +} + +fn proposed_event( + type_ type_name: String, + data data: json.Json, + tags tags: List(factos.Tag), +) -> factos_pog.Proposed(Event) { + factos_pog.new_proposed( + type_: factos.event_type(type_name), + version: 1, + data: data |> json.to_string |> bit_array.from_string, + ) + |> factos_pog.with_tags(tags:) +} + +fn decode_event( + stored: factos_pog.StoredEvent, +) -> Result(factos.Decoded(Event), factos_pog.DecodeError) { + case factos.event_type_name(stored.type_), stored.version { + "SignUpInitiated", 1 -> { + use data <- result.try(decode_payload(stored)) + use initiated_minute <- result.try(decode_initiated_minute( + stored.metadata, + )) + factos_pog.decoded_event( + stored, + event: SignUpInitiated( + email_address: data.0, + otp: data.1, + name: data.2, + initiated_minute:, + ), + ) + } + "SignUpConfirmed", 1 -> { + use data <- result.try(decode_payload(stored)) + factos_pog.decoded_event( + stored, + event: SignUpConfirmed(email_address: data.0, otp: data.1, name: data.2), + ) + } + _, _ -> Error(factos_pog.UnknownEvent) + } +} + +fn decode_payload( + stored: factos_pog.StoredEvent, +) -> Result(#(String, String, String), factos_pog.DecodeError) { + json.parse_bits(from: stored.data, using: sign_up_decoder()) + |> result.replace_error(factos_pog.InvalidData) +} + +fn decode_initiated_minute( + metadata: factos.Metadata, +) -> Result(Int, factos_pog.DecodeError) { + use value <- result.try( + factos.metadata_get(metadata, initiated_minute_key) + |> result.replace_error(factos_pog.InvalidData), + ) + int.parse(value) |> result.replace_error(factos_pog.InvalidData) +} + +fn sign_up_decoder() -> decode.Decoder(#(String, String, String)) { + use email_address <- decode.field("email_address", decode.string) + use otp <- decode.field("otp", decode.string) + use name <- decode.field("name", decode.string) + decode.success(#(email_address, otp, name)) +} + +pub fn dispatcher() -> Result( + factos_pog.Dispatcher(Event), + factos_pog.DispatcherConfigurationError, +) { + factos_pog.dispatcher(codec: codec(), subscriptions: []) +} + +pub fn dispatch( + connection: pog.Connection, + dispatcher: factos_pog.Dispatcher(Event), + command: Command, + event_id: fn() -> String, +) -> Result(factos_pog.Dispatch(Event), factos_pog.Error(Error)) { + factos_pog.new_dispatch( + connection:, + stream: stream(command), + decider: factos.decider(initial: initial(command), decide:, evolve:), + dispatcher:, + ) + |> factos_pog.with_query(query(command)) + |> factos_pog.dispatch(command, event_id:) +} + +fn query(command: Command) -> factos.Query { + let #(email_address, otp) = case command { + InitiateSignUp( + sign_up_id: _, + email_address:, + otp:, + name: _, + initiated_minute: _, + ) + | ConfirmSignUp(confirmation_id: _, email_address:, otp:, current_minute: _) -> #( + email_address, + otp, + ) + } + factos.query([ + factos.query_item( + types: [ + factos.event_type("SignUpInitiated"), + factos.event_type("SignUpConfirmed"), + ], + tags: [ + factos.tag("email:" <> email_address), + factos.tag("otp:" <> otp), + ], + ), + ]) +} + +fn stream(command: Command) -> String { + case command { + InitiateSignUp(sign_up_id:, ..) -> "sign-up-" <> sign_up_id + ConfirmSignUp(confirmation_id:, ..) -> "confirmation-" <> confirmation_id + } +} diff --git a/examples/opt_in_token/test/opt_in_token_test.gleam b/examples/opt_in_token/test/opt_in_token_test.gleam new file mode 100644 index 0000000..84aadfc --- /dev/null +++ b/examples/opt_in_token/test/opt_in_token_test.gleam @@ -0,0 +1,25 @@ +import gleeunit +import opt_in_token_dev + +pub type Timeout(a) { + Timeout(time: Int, function: fn() -> a) +} + +pub fn main() -> Nil { + gleeunit.main() +} + +pub fn opt_in_token_example_test_() -> Timeout(Nil) { + use <- Timeout(120) + let assert Ok(result) = opt_in_token_dev.run() + assert result + == opt_in_token_dev.ExampleResult( + initiated_sign_ups: 5, + confirmed_sign_ups: 3, + sequential_rejections: 4, + concurrent_acceptances: 1, + concurrent_rejections: 1, + stored_events: 8, + ) + Nil +} diff --git a/examples/performance/.gitignore b/examples/performance/.gitignore new file mode 100644 index 0000000..982745b --- /dev/null +++ b/examples/performance/.gitignore @@ -0,0 +1,7 @@ +*.beam +*.ez +/build +**/build +node_modules +**/node_modules +erl_crash.dump diff --git a/examples/performance/gleam.toml b/examples/performance/gleam.toml new file mode 100644 index 0000000..6fb1a29 --- /dev/null +++ b/examples/performance/gleam.toml @@ -0,0 +1,15 @@ +name = "factos_pog_performance" +version = "1.0.0" + +[dependencies] +factos = { path = "../.." } +factos_pog = { path = "../../backends/factos_pog" } +gleam_stdlib = ">= 1.0.0 and < 2.0.0" +gleam_erlang = ">= 1.0.0 and < 2.0.0" +gleam_otp = ">= 1.2.0 and < 2.0.0" +pog = { git = "https://github.com/foxfriends/pog.git", ref = "919fd6ac96095ea11fa7c940b17eaece49cc5993" } +simplifile = ">= 2.5.0 and < 3.0.0" +testcontainer = ">= 1.0.2 and < 2.0.0" +testcontainer_formulas = ">= 1.0.0 and < 2.0.0" +youid = ">= 1.5.4 and < 2.0.0" +gleamy_bench = ">= 0.6.0 and < 1.0.0" diff --git a/examples/performance/manifest.toml b/examples/performance/manifest.toml new file mode 100644 index 0000000..1e75cbf --- /dev/null +++ b/examples/performance/manifest.toml @@ -0,0 +1,45 @@ +# Do not manually edit this file, it is managed by Gleam. +# +# This file locks the dependency versions used, to make your build +# deterministic and to prevent unexpected versions from being included +# in your application. +# +# You should check this file into your source control repository. + +packages = [ + { name = "backoff", version = "1.1.6", build_tools = ["rebar3"], requirements = [], otp_app = "backoff", source = "hex", outer_checksum = "CF0CFFF8995FB20562F822E5CC47D8CCF664C5ECDC26A684CBE85C225F9D7C39" }, + { name = "cowl", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "cowl", source = "hex", outer_checksum = "7849E7C789D7228243A4253138FC883720A0BB44AEF406102328CADC64C3CA2B" }, + { name = "envie", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envie", source = "hex", outer_checksum = "E7EBA39310F32A40BF3EDDD7CD9C7A2BC289909983D357411C22873415BC322A" }, + { name = "exception", version = "2.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "6BDEA95248093599391C3B5DF1835C5C6A86C353C2F99CE539B450E3432FE117" }, + { name = "factos", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], source = "local", path = "../.." }, + { name = "factos_pog", version = "2.0.0", build_tools = ["gleam"], requirements = ["factos", "gleam_erlang", "gleam_otp", "gleam_stdlib", "gleam_time", "pog"], source = "local", path = "../../backends/factos_pog" }, + { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, + { name = "gleam_crypto", version = "1.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "2DE9E4EF53CF6FEE049D4F765731F7178F7A11AEFAE00EEE63BF7536B354AD3F" }, + { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, + { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, + { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, + { name = "gleam_stdlib", version = "1.0.5", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "CEE5B6C076A85B45F60C585F4316C63EC8B7127C119D5738C3958A9C4D50404E" }, + { name = "gleam_time", version = "1.8.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "533D8723774D61AD4998324F5DD1DABDCDBFABAFB9E87CB5D03C6955448FC97D" }, + { name = "gleamy_bench", version = "0.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleamy_bench", source = "hex", outer_checksum = "DEF68E4B097A56781282F0F9D48371A0ABBCDDCF89CAD05B28C3BEDD6B2E8DF3" }, + { name = "opentelemetry_api", version = "1.5.0", build_tools = ["rebar3", "mix"], requirements = [], otp_app = "opentelemetry_api", source = "hex", outer_checksum = "F53EC8A1337AE4A487D43AC89DA4BD3A3C99DDF576655D071DEED8B56A2D5DDA" }, + { name = "pg_types", version = "0.6.0", build_tools = ["rebar3"], requirements = [], otp_app = "pg_types", source = "hex", outer_checksum = "9949A4849DD13408FA249AB7B745E0D2DFDB9532AEE2B9722326E33CD082A778" }, + { name = "pgo", version = "0.20.0", build_tools = ["rebar3"], requirements = ["backoff", "opentelemetry_api", "pg_types"], otp_app = "pgo", source = "hex", outer_checksum = "2F11E6649CEB38E569EF56B16BE1D04874AE5B11A02867080A2817CE423C683B" }, + { name = "pog", version = "4.1.0", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_otp", "gleam_stdlib", "gleam_time", "pgo"], source = "git", repo = "https://github.com/foxfriends/pog.git", commit = "919fd6ac96095ea11fa7c940b17eaece49cc5993" }, + { name = "simplifile", version = "2.7.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "A2727627B063E87351934C7F7F008F2D1FDB16F6DE0B8C79F9E46459CFC9C164" }, + { name = "testcontainer", version = "1.0.2", build_tools = ["gleam"], requirements = ["cowl", "envie", "gleam_erlang", "gleam_json", "gleam_stdlib"], otp_app = "testcontainer", source = "hex", outer_checksum = "784768485ED2380AA543A0CC3F06F7A368B0DD209102C57E800E0A87E1D2FC81" }, + { name = "testcontainer_formulas", version = "1.0.0", build_tools = ["gleam"], requirements = ["cowl", "gleam_stdlib", "testcontainer"], otp_app = "testcontainer_formulas", source = "hex", outer_checksum = "F9A86A2F8400A0C72FE98F56EF5B3FD1CE10F0A63D968A1C57EA0087A3E5802B" }, + { name = "youid", version = "1.6.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_stdlib", "gleam_time"], otp_app = "youid", source = "hex", outer_checksum = "7A3ABA44B1B38BC2BDCB5474C5317AA372BE58DFBC649815EE08B03526DDA18D" }, +] + +[requirements] +factos = { path = "../.." } +factos_pog = { path = "../../backends/factos_pog" } +gleam_erlang = { version = ">= 1.0.0 and < 2.0.0" } +gleam_otp = { version = ">= 1.2.0 and < 2.0.0" } +gleam_stdlib = { version = ">= 1.0.0 and < 2.0.0" } +gleamy_bench = { version = ">= 0.6.0 and < 1.0.0" } +pog = { git = "https://github.com/foxfriends/pog.git", ref = "919fd6ac96095ea11fa7c940b17eaece49cc5993" } +simplifile = { version = ">= 2.5.0 and < 3.0.0" } +testcontainer = { version = ">= 1.0.2 and < 2.0.0" } +testcontainer_formulas = { version = ">= 1.0.0 and < 2.0.0" } +youid = { version = ">= 1.5.4 and < 2.0.0" } diff --git a/examples/performance/src/factos_pog_performance.gleam b/examples/performance/src/factos_pog_performance.gleam new file mode 100644 index 0000000..f41c87c --- /dev/null +++ b/examples/performance/src/factos_pog_performance.gleam @@ -0,0 +1,889 @@ +import factos +import factos/factos_pog +import gleam/bit_array +import gleam/dynamic/decode +import gleam/erlang/application +import gleam/erlang/process +import gleam/float +import gleam/int +import gleam/io +import gleam/list +import gleam/option.{Some} +import gleam/otp/actor +import gleam/result +import gleam/string +import gleamy/bench +import pog +import simplifile +import testcontainer +import testcontainer/error as testcontainer_error +import testcontainer_formulas/postgres +import youid/uuid + +const workers = 8 + +const dispatches_per_worker = 2500 + +const events_per_dispatch = 100 + +type Command { + AppendBatch(batch: Int, event_count: Int) +} + +type State { + BenchmarkState +} + +type Event { + UserRegistered(user_id: Int) + EmailChanged(email: String) + BalanceAdjusted(delta: Int) + UserSuspended(reason: String) +} + +type Effect { + EventObserved(event_id: String) +} + +type WorkerMessage { + WorkerDone(worker: Int, result: Result(Nil, factos_pog.Error(Nil))) +} + +type BenchmarkJob { + DispatchJob(reply_to: process.Subject(WorkerMessage)) +} + +type BenchmarkWorkerStarted { + BenchmarkWorkerStarted(jobs: process.Subject(BenchmarkJob)) +} + +type StreamMode { + FreshStream(prefix: String) + HotStream(name: String) +} + +type BenchmarkInput { + BenchmarkInput( + connection: pog.Connection, + decider: factos.Decider(Command, State, Event, Nil), + dispatcher: factos_pog.Dispatcher(Event), + event_count: Int, + worker_count: Int, + stream_mode: StreamMode, + ) +} + +fn decider() -> factos.Decider(Command, State, Event, Nil) { + factos.decider(initial: BenchmarkState, decide:, evolve:) +} + +fn decide(state: State, command: Command) -> Result(List(Event), Nil) { + let BenchmarkState = state + let AppendBatch(batch:, event_count:) = command + Ok( + build_events( + batch, + event_index: 0, + variant_index: 0, + remaining: event_count, + accumulated: [], + ), + ) +} + +fn build_events( + batch: Int, + event_index event_index: Int, + variant_index variant_index: Int, + remaining remaining: Int, + accumulated accumulated: List(Event), +) -> List(Event) { + case remaining <= 0 { + True -> list.reverse(accumulated) + False -> { + let value = batch * events_per_dispatch + event_index + let event = case variant_index { + 0 -> UserRegistered(user_id: value) + 1 -> EmailChanged(email: int.to_string(value) <> "@example.com") + 2 -> BalanceAdjusted(delta: value) + _ -> UserSuspended(reason: "performance benchmark") + } + let next_variant = case variant_index >= 3 { + True -> 0 + False -> variant_index + 1 + } + build_events( + batch, + event_index: event_index + 1, + variant_index: next_variant, + remaining: remaining - 1, + accumulated: [event, ..accumulated], + ) + } + } +} + +fn evolve(state: State, event: Event) -> State { + let BenchmarkState = state + case event { + UserRegistered(user_id: _) -> BenchmarkState + EmailChanged(email: _) -> BenchmarkState + BalanceAdjusted(delta: _) -> BenchmarkState + UserSuspended(reason: _) -> BenchmarkState + } +} + +fn codec(tags: List(factos.Tag)) -> factos_pog.EventCodec(Event) { + factos_pog.codec(encode: fn(event) { encode(event, tags) }, decode:) +} + +fn encode(event: Event, tags: List(factos.Tag)) -> factos_pog.Proposed(Event) { + let #(type_name, data) = case event { + UserRegistered(user_id:) -> #( + "performance.user_registered", + int.to_string(user_id), + ) + EmailChanged(email:) -> #("performance.email_changed", email) + BalanceAdjusted(delta:) -> #( + "performance.balance_adjusted", + int.to_string(delta), + ) + UserSuspended(reason:) -> #("performance.user_suspended", reason) + } + + factos_pog.new_proposed( + type_: factos.event_type(type_name), + version: 1, + data: bit_array.from_string(data), + ) + |> factos_pog.with_tags(tags:) +} + +fn decode( + stored: factos_pog.StoredEvent, +) -> Result(factos.Decoded(Event), factos_pog.DecodeError) { + use data <- result.try( + bit_array.to_string(stored.data) + |> result.replace_error(factos_pog.InvalidData), + ) + + case factos.event_type_name(stored.type_) { + "performance.user_registered" -> { + use user_id <- result.try( + int.parse(data) |> result.replace_error(factos_pog.InvalidData), + ) + factos_pog.decoded_event(stored, event: UserRegistered(user_id:)) + } + "performance.email_changed" -> + factos_pog.decoded_event(stored, event: EmailChanged(email: data)) + "performance.balance_adjusted" -> { + use delta <- result.try( + int.parse(data) |> result.replace_error(factos_pog.InvalidData), + ) + factos_pog.decoded_event(stored, event: BalanceAdjusted(delta:)) + } + "performance.user_suspended" -> + factos_pog.decoded_event(stored, event: UserSuspended(reason: data)) + _ -> Error(factos_pog.UnknownEvent) + } +} + +fn empty_dispatcher() -> factos_pog.Dispatcher(Event) { + configured_dispatcher(tags: [], subscriptions: []) +} + +fn tagged_dispatcher() -> factos_pog.Dispatcher(Event) { + configured_dispatcher(tags: [factos.tag("performance")], subscriptions: []) +} + +fn durable_dispatcher() -> factos_pog.Dispatcher(Event) { + configured_dispatcher(tags: [factos.tag("performance")], subscriptions: [ + factos_pog.subscription( + name: "performance.index.v1", + reactor: factos.reactor(react: fn(recorded) { + [EventObserved(event_id: recorded.id)] + }), + codec: factos_pog.effect_codec(encode: fn(effect) { + let EventObserved(event_id:) = effect + factos_pog.proposed_effect( + consumer: "performance.benchmark", + key: event_id, + target: "performance-index", + type_: "performance.event_observed", + metadata: factos.empty_metadata(), + payload: bit_array.from_string(event_id), + ) + }), + ), + ]) +} + +fn configured_dispatcher( + tags tags: List(factos.Tag), + subscriptions subscriptions: List(factos_pog.Subscription(Event)), +) -> factos_pog.Dispatcher(Event) { + let assert Ok(dispatcher) = + factos_pog.dispatcher(codec: codec(tags), subscriptions:) + dispatcher +} + +pub fn main() -> Nil { + let assert Ok(Nil) = run() + Nil +} + +fn run() -> Result(Nil, testcontainer_error.Error) { + use postgres_container <- testcontainer.with_formula( + postgres.new() |> postgres.formula(), + ) + let #(pool_pid, connection) = start_connection(postgres_container) + execute_migration_file(connection) + + let total_dispatches = workers * dispatches_per_worker + let total_events = total_dispatches * events_per_dispatch + let benchmark_decider = decider() + let benchmark_dispatcher = empty_dispatcher() + let worker_messages = process.new_subject() + + io.println("factos_pog heterogeneous dispatch performance") + io.println("workers: " <> int.to_string(workers)) + io.println("dispatches: " <> int.to_string(total_dispatches)) + io.println("events per dispatch: " <> int.to_string(events_per_dispatch)) + io.println("events: " <> int.to_string(total_events)) + + let started_at = bench.now() + spawn_workers( + connection, + benchmark_decider, + benchmark_dispatcher, + worker_messages, + worker: workers, + ) + wait_for_workers(worker_messages, remaining: workers) + let finished_at = bench.now() + + let elapsed_milliseconds = finished_at -. started_at + assert elapsed_milliseconds >. 0.0 + let seconds = elapsed_milliseconds /. 1000.0 + let dispatches_per_second = int.to_float(total_dispatches) /. seconds + let events_per_second = int.to_float(total_events) /. seconds + + io.println("elapsed_ms: " <> float.to_string(elapsed_milliseconds)) + io.println( + "dispatches_per_second: " <> float.to_string(dispatches_per_second), + ) + io.println("events_per_second: " <> float.to_string(events_per_second)) + + verify_preload(connection) + + run_event_count_benchmarks( + connection, + benchmark_decider, + benchmark_dispatcher, + ) + run_worker_count_benchmarks( + connection, + benchmark_decider, + benchmark_dispatcher, + ) + run_stream_benchmarks(connection, benchmark_decider, benchmark_dispatcher) + run_dispatcher_benchmarks(connection, benchmark_decider, benchmark_dispatcher) + io.println("benchmark complete") + + process.send_exit(pool_pid) + process.sleep(100) + Ok(Nil) +} + +fn spawn_workers( + connection: pog.Connection, + benchmark_decider: factos.Decider(Command, State, Event, Nil), + benchmark_dispatcher: factos_pog.Dispatcher(Event), + worker_messages: process.Subject(WorkerMessage), + worker worker: Int, +) -> Nil { + case worker <= 0 { + True -> Nil + False -> { + process.spawn(fn() { + let result = + run_worker( + connection, + benchmark_decider, + benchmark_dispatcher, + worker, + dispatch_index: 0, + ) + process.send(worker_messages, WorkerDone(worker:, result:)) + }) + spawn_workers( + connection, + benchmark_decider, + benchmark_dispatcher, + worker_messages, + worker: worker - 1, + ) + } + } +} + +fn run_worker( + connection: pog.Connection, + benchmark_decider: factos.Decider(Command, State, Event, Nil), + benchmark_dispatcher: factos_pog.Dispatcher(Event), + worker: Int, + dispatch_index dispatch_index: Int, +) -> Result(Nil, factos_pog.Error(Nil)) { + case dispatch_index >= dispatches_per_worker { + True -> Ok(Nil) + False -> { + let stream_name = + "performance-" + <> int.to_string(worker) + <> "-" + <> int.to_string(dispatch_index) + let batch = { worker - 1 } * dispatches_per_worker + dispatch_index + use _ <- result.try(dispatch_once( + connection, + benchmark_decider, + benchmark_dispatcher, + stream_name, + batch, + events_per_dispatch, + )) + run_worker( + connection, + benchmark_decider, + benchmark_dispatcher, + worker, + dispatch_index: dispatch_index + 1, + ) + } + } +} + +fn wait_for_workers( + worker_messages: process.Subject(WorkerMessage), + remaining remaining: Int, +) -> Nil { + case remaining <= 0 { + True -> Nil + False -> + case process.receive_forever(worker_messages) { + WorkerDone(worker: _, result: Ok(Nil)) -> + wait_for_workers(worker_messages, remaining: remaining - 1) + WorkerDone(worker:, result: Error(error)) -> + panic as { + "performance worker " + <> int.to_string(worker) + <> " failed: " + <> factos_pog.error_to_string(error, fn(_) { "nil" }) + } + } + } +} + +fn dispatch_once( + connection: pog.Connection, + benchmark_decider: factos.Decider(Command, State, Event, Nil), + benchmark_dispatcher: factos_pog.Dispatcher(Event), + stream_name: String, + batch: Int, + event_count: Int, +) -> Result(factos_pog.Dispatch(Event), factos_pog.Error(Nil)) { + factos_pog.new_dispatch( + connection:, + stream: stream_name, + decider: benchmark_decider, + dispatcher: benchmark_dispatcher, + ) + |> factos_pog.with_retry_attempts(attempts: 100) + |> factos_pog.dispatch( + AppendBatch(batch:, event_count:), + event_id: uuid.v4_string, + ) +} + +fn run_event_count_benchmarks( + connection: pog.Connection, + benchmark_decider: factos.Decider(Command, State, Event, Nil), + benchmark_dispatcher: factos_pog.Dispatcher(Event), +) -> Nil { + io.println("event count matrix") + bench.run( + [ + benchmark_input( + "1 event", + connection, + benchmark_decider, + benchmark_dispatcher, + 1, + 1, + FreshStream(prefix: "performance-event-count-"), + ), + benchmark_input( + "5 events", + connection, + benchmark_decider, + benchmark_dispatcher, + 5, + 1, + FreshStream(prefix: "performance-event-count-"), + ), + benchmark_input( + "10 events", + connection, + benchmark_decider, + benchmark_dispatcher, + 10, + 1, + FreshStream(prefix: "performance-event-count-"), + ), + benchmark_input( + "100 events", + connection, + benchmark_decider, + benchmark_dispatcher, + 100, + 1, + FreshStream(prefix: "performance-event-count-"), + ), + ], + [ + bench.Function( + label: "fresh-stream dispatch", + function: benchmark_dispatch, + ), + ], + benchmark_options(), + ) + |> bench.table([bench.IPS, bench.Min, bench.Mean, bench.P(99)]) + |> io.println +} + +fn run_worker_count_benchmarks( + connection: pog.Connection, + benchmark_decider: factos.Decider(Command, State, Event, Nil), + benchmark_dispatcher: factos_pog.Dispatcher(Event), +) -> Nil { + io.println("worker count matrix") + bench.run( + [ + benchmark_input( + "1 worker", + connection, + benchmark_decider, + benchmark_dispatcher, + 100, + 1, + FreshStream(prefix: "performance-worker-count-"), + ), + benchmark_input( + "2 workers", + connection, + benchmark_decider, + benchmark_dispatcher, + 100, + 2, + FreshStream(prefix: "performance-worker-count-"), + ), + benchmark_input( + "4 workers", + connection, + benchmark_decider, + benchmark_dispatcher, + 100, + 4, + FreshStream(prefix: "performance-worker-count-"), + ), + benchmark_input( + "8 workers", + connection, + benchmark_decider, + benchmark_dispatcher, + 100, + 8, + FreshStream(prefix: "performance-worker-count-"), + ), + benchmark_input( + "16 workers", + connection, + benchmark_decider, + benchmark_dispatcher, + 100, + 16, + FreshStream(prefix: "performance-worker-count-"), + ), + ], + [ + bench.SetupFunction( + label: "persistent worker group", + setup_function: setup_concurrent_dispatch, + ), + ], + benchmark_options(), + ) + |> bench.table([bench.IPS, bench.Min, bench.Mean, bench.P(99)]) + |> io.println + io.println( + "worker matrix IPS is groups per second; multiply by the worker count for dispatches per second", + ) +} + +fn run_stream_benchmarks( + connection: pog.Connection, + benchmark_decider: factos.Decider(Command, State, Event, Nil), + benchmark_dispatcher: factos_pog.Dispatcher(Event), +) -> Nil { + let hot_stream = "performance-hot-10k" + let assert Ok(_) = + dispatch_once( + connection, + benchmark_decider, + benchmark_dispatcher, + hot_stream, + 0, + 10_000, + ) + + io.println("stream history matrix") + bench.run( + [ + benchmark_input( + "fresh stream", + connection, + benchmark_decider, + benchmark_dispatcher, + 100, + 1, + FreshStream(prefix: "performance-stream-comparison-"), + ), + benchmark_input( + "hot stream (10k)", + connection, + benchmark_decider, + benchmark_dispatcher, + 100, + 1, + HotStream(name: hot_stream), + ), + ], + [ + bench.Function(label: "100-event dispatch", function: benchmark_dispatch), + ], + benchmark_options(), + ) + |> bench.table([bench.IPS, bench.Min, bench.Mean, bench.P(99)]) + |> io.println +} + +fn run_dispatcher_benchmarks( + connection: pog.Connection, + benchmark_decider: factos.Decider(Command, State, Event, Nil), + empty_dispatcher empty_dispatcher: factos_pog.Dispatcher(Event), +) -> Nil { + let tagged_dispatcher = tagged_dispatcher() + let durable_dispatcher = durable_dispatcher() + + io.println("dispatcher write-amplification matrix") + bench.run( + [ + benchmark_input( + "empty dispatcher", + connection, + benchmark_decider, + empty_dispatcher, + 100, + 1, + FreshStream(prefix: "performance-empty-dispatcher-"), + ), + benchmark_input( + "one tag per event", + connection, + benchmark_decider, + tagged_dispatcher, + 100, + 1, + FreshStream(prefix: "performance-tagged-dispatcher-"), + ), + benchmark_input( + "tag + durable", + connection, + benchmark_decider, + durable_dispatcher, + 100, + 1, + FreshStream(prefix: "performance-durable-dispatcher-"), + ), + ], + [ + bench.Function(label: "100-event dispatch", function: benchmark_dispatch), + ], + benchmark_options(), + ) + |> bench.table([bench.IPS, bench.Min, bench.Mean, bench.P(99)]) + |> io.println + + assert_dispatcher_writes(connection) +} + +fn assert_dispatcher_writes(connection: pog.Connection) -> Nil { + let assert Ok(tag_counts) = + pog.query("select count(*) from factos_event_tags") + |> pog.returning(int_decoder()) + |> pog.execute(on: connection) + let assert [tag_count] = tag_counts.rows + assert tag_count > 0 + + let assert Ok(outbox_counts) = + pog.query("select count(*) from factos_outbox") + |> pog.returning(int_decoder()) + |> pog.execute(on: connection) + let assert [outbox_count] = outbox_counts.rows + assert outbox_count > 0 + + io.println("verified dispatcher matrix: tags and durable effects persisted") +} + +fn benchmark_input( + label: String, + connection: pog.Connection, + benchmark_decider: factos.Decider(Command, State, Event, Nil), + benchmark_dispatcher: factos_pog.Dispatcher(Event), + event_count: Int, + worker_count: Int, + stream_mode: StreamMode, +) -> bench.Input(BenchmarkInput) { + bench.Input( + label:, + value: BenchmarkInput( + connection:, + decider: benchmark_decider, + dispatcher: benchmark_dispatcher, + event_count:, + worker_count:, + stream_mode:, + ), + ) +} + +fn benchmark_options() -> List(bench.Option) { + [ + bench.Warmup(ms: 1000), + bench.Duration(ms: 10_000), + bench.Decimals(n: 3), + ] +} + +fn benchmark_dispatch(input: BenchmarkInput) -> Nil { + let BenchmarkInput( + connection:, + decider:, + dispatcher:, + event_count:, + stream_mode:, + worker_count: _, + ) = input + let assert Ok(_) = + dispatch_once( + connection, + decider, + dispatcher, + benchmark_stream_name(stream_mode), + 0, + event_count, + ) + Nil +} + +fn benchmark_stream_name(stream_mode: StreamMode) -> String { + case stream_mode { + FreshStream(prefix:) -> prefix <> uuid.v4_string() + HotStream(name:) -> name + } +} + +fn setup_concurrent_dispatch( + input: BenchmarkInput, +) -> fn(BenchmarkInput) -> Nil { + let BenchmarkInput(worker_count:, ..) = input + let worker_jobs = start_benchmark_workers(input, worker: worker_count) + + fn(_input: BenchmarkInput) { + let worker_messages = process.new_subject() + send_benchmark_jobs(worker_jobs, worker_messages) + wait_for_workers(worker_messages, remaining: worker_count) + } +} + +fn start_benchmark_workers( + input: BenchmarkInput, + worker worker: Int, +) -> List(process.Subject(BenchmarkJob)) { + case worker <= 0 { + True -> [] + False -> { + let started = process.new_subject() + process.spawn(fn() { + let jobs = process.new_subject() + process.send(started, BenchmarkWorkerStarted(jobs:)) + run_benchmark_worker(input, jobs, worker) + }) + let BenchmarkWorkerStarted(jobs:) = process.receive_forever(started) + [jobs, ..start_benchmark_workers(input, worker: worker - 1)] + } + } +} + +fn run_benchmark_worker( + input: BenchmarkInput, + jobs: process.Subject(BenchmarkJob), + worker: Int, +) -> Nil { + let DispatchJob(reply_to:) = process.receive_forever(jobs) + let BenchmarkInput( + connection:, + decider:, + dispatcher:, + event_count:, + stream_mode:, + worker_count: _, + ) = input + let result = + dispatch_once( + connection, + decider, + dispatcher, + benchmark_stream_name(stream_mode), + 0, + event_count, + ) + |> result.map(fn(_) { Nil }) + process.send(reply_to, WorkerDone(worker:, result:)) + run_benchmark_worker(input, jobs, worker) +} + +fn send_benchmark_jobs( + worker_jobs: List(process.Subject(BenchmarkJob)), + worker_messages: process.Subject(WorkerMessage), +) -> Nil { + case worker_jobs { + [] -> Nil + [jobs, ..remaining] -> { + process.send(jobs, DispatchJob(reply_to: worker_messages)) + send_benchmark_jobs(remaining, worker_messages) + } + } +} + +fn verify_preload(connection: pog.Connection) -> Nil { + let assert Ok(type_counts) = + pog.query( + "select type, count(*) from factos_events group by type order by type", + ) + |> pog.returning(type_count_decoder()) + |> pog.execute(on: connection) + assert type_counts.rows + == [ + #("performance.balance_adjusted", 500_000), + #("performance.email_changed", 500_000), + #("performance.user_registered", 500_000), + #("performance.user_suspended", 500_000), + ] + + let assert Ok(stream_counts) = + pog.query("select count(distinct stream) from factos_events") + |> pog.returning(int_decoder()) + |> pog.execute(on: connection) + assert stream_counts.rows == [20_000] + + io.println("verified preload: 2000000 events, 500000 per type, 20000 streams") +} + +fn type_count_decoder() -> decode.Decoder(#(String, Int)) { + use type_name <- decode.field(0, decode.string) + use count <- decode.field(1, decode.int) + decode.success(#(type_name, count)) +} + +fn int_decoder() -> decode.Decoder(Int) { + use value <- decode.field(0, decode.int) + decode.success(value) +} + +fn start_connection( + postgres_container: postgres.PostgresContainer, +) -> #(process.Pid, pog.Connection) { + let postgres.PostgresContainer(host:, port:, database:, username:, ..) = + postgres_container + let pool_name = process.new_name("factos_pog_performance") + let config = + pog.default_config(pool_name) + |> pog.host(host) + |> pog.port(port) + |> pog.database(database) + |> pog.user(username) + |> pog.password(Some("postgres")) + |> pog.ssl(pog.SslDisabled) + + let assert Ok(actor.Started(pid:, ..)) = pog.start(config) + process.sleep(100) + #(pid, pog.named_connection(pool_name)) +} + +fn execute_migration_file(connection: pog.Connection) -> Nil { + let assert Ok(priv_directory) = application.priv_directory("factos_pog") + let assert Ok(sql) = simplifile.read(priv_directory <> "/migrations.sql") + + sql + |> split_sql_script + |> list.each(fn(statement) { + let assert Ok(_) = pog.query(statement) |> pog.execute(on: connection) + Nil + }) +} + +fn split_sql_script(sql: String) -> List(String) { + string.split(sql, "$function$") + |> split_sql_sections("", []) + |> list.reverse + |> list.map(string.trim) + |> list.filter(fn(statement) { statement != "" }) +} + +fn split_sql_sections( + sections: List(String), + current: String, + completed: List(String), +) -> List(String) { + case sections { + [] -> [current, ..completed] + [outside] -> { + let #(current, completed) = + split_sql_outside(string.split(outside, ";"), current, completed) + [current, ..completed] + } + [outside, function_body, ..remaining] -> { + let #(current, completed) = + split_sql_outside(string.split(outside, ";"), current, completed) + split_sql_sections( + remaining, + current <> "$function$" <> function_body <> "$function$", + completed, + ) + } + } +} + +fn split_sql_outside( + parts: List(String), + current: String, + completed: List(String), +) -> #(String, List(String)) { + case parts { + [] -> #(current, completed) + [last] -> #(current <> last, completed) + [statement, ..remaining] -> + split_sql_outside(remaining, "", [current <> statement, ..completed]) + } +} diff --git a/examples/performance/test/factos_pog_performance_test.gleam b/examples/performance/test/factos_pog_performance_test.gleam new file mode 100644 index 0000000..4ead3b1 --- /dev/null +++ b/examples/performance/test/factos_pog_performance_test.gleam @@ -0,0 +1,3 @@ +pub fn main() { + Nil +} diff --git a/examples/prevent_record_duplication/.gitignore b/examples/prevent_record_duplication/.gitignore new file mode 100644 index 0000000..982745b --- /dev/null +++ b/examples/prevent_record_duplication/.gitignore @@ -0,0 +1,7 @@ +*.beam +*.ez +/build +**/build +node_modules +**/node_modules +erl_crash.dump diff --git a/examples/prevent_record_duplication/dev/prevent_record_duplication_dev.gleam b/examples/prevent_record_duplication/dev/prevent_record_duplication_dev.gleam new file mode 100644 index 0000000..0578414 --- /dev/null +++ b/examples/prevent_record_duplication/dev/prevent_record_duplication_dev.gleam @@ -0,0 +1,287 @@ +import factos +import factos/factos_pog +import gleam/erlang/application +import gleam/erlang/process +import gleam/io +import gleam/list +import gleam/option +import gleam/otp/actor +import gleam/string +import pog +import prevent_record_duplication +import simplifile +import testcontainer +import testcontainer/error as testcontainer_error +import testcontainer_formulas/postgres +import youid/uuid + +pub type ExampleResult { + ExampleResult( + sequential_acceptances: Int, + sequential_resubmissions: Int, + concurrent_acceptances: Int, + concurrent_resubmissions: Int, + stored_orders: Int, + ) +} + +type WorkerMessage { + WorkerReady(worker: String, release: process.Subject(Nil)) + WorkerFinished( + worker: String, + result: Result( + factos_pog.Dispatch(prevent_record_duplication.Event), + factos_pog.Error(prevent_record_duplication.Error), + ), + ) +} + +pub fn run() -> Result(ExampleResult, testcontainer_error.Error) { + use postgres_container <- testcontainer.with_formula( + postgres.new() |> postgres.formula(), + ) + let #(pool_pid, connection) = start_connection(postgres_container) + install_event_store(connection) + let dispatcher = configured_dispatcher() + + let assert Ok(_) = + prevent_record_duplication.dispatch( + connection, + dispatcher, + prevent_record_duplication.PlaceOrder( + order_id: "o12345", + idempotency_token: "11111", + ), + uuid.v4_string, + ) + let assert Error(factos_pog.DomainError( + prevent_record_duplication.Resubmission, + )) = + prevent_record_duplication.dispatch( + connection, + dispatcher, + prevent_record_duplication.PlaceOrder( + order_id: "o54321", + idempotency_token: "11111", + ), + uuid.v4_string, + ) + let assert Ok(_) = + prevent_record_duplication.dispatch( + connection, + dispatcher, + prevent_record_duplication.PlaceOrder( + order_id: "o54321", + idempotency_token: "22222", + ), + uuid.v4_string, + ) + + let concurrent_results = run_concurrent_scenario(connection, dispatcher) + let concurrent_acceptances = + list.count(concurrent_results, where: is_accepted) + let concurrent_resubmissions = + list.count(concurrent_results, where: is_resubmission) + assert concurrent_acceptances == 1 + assert concurrent_resubmissions == 1 + + let assert Ok(stored_events) = + factos_pog.read_events_after( + connection, + query: factos.AllEvents, + after: factos.NoPosition, + limit: 10, + codec: prevent_record_duplication.codec(), + ) + assert list.length(stored_events) == 3 + assert count_token(stored_events, "11111") == 1 + assert count_token(stored_events, "22222") == 1 + assert count_token(stored_events, "33333") == 1 + + process.send_exit(pool_pid) + process.sleep(100) + Ok(ExampleResult( + sequential_acceptances: 2, + sequential_resubmissions: 1, + concurrent_acceptances:, + concurrent_resubmissions:, + stored_orders: list.length(stored_events), + )) +} + +pub fn main() -> Nil { + let assert Ok(ExampleResult( + sequential_acceptances: 2, + sequential_resubmissions: 1, + concurrent_acceptances: 1, + concurrent_resubmissions: 1, + stored_orders: 3, + )) = run() + io.println("first submission: accepted") + io.println("same token: Re-submission") + io.println("new token: accepted") + io.println("concurrent same token: 1 accepted, 1 rejected") + io.println("persisted orders: 3") +} + +fn configured_dispatcher() -> factos_pog.Dispatcher( + prevent_record_duplication.Event, +) { + let assert Ok(dispatcher) = prevent_record_duplication.dispatcher() + dispatcher +} + +fn run_concurrent_scenario( + connection: pog.Connection, + dispatcher: factos_pog.Dispatcher(prevent_record_duplication.Event), +) -> List( + Result( + factos_pog.Dispatch(prevent_record_duplication.Event), + factos_pog.Error(prevent_record_duplication.Error), + ), +) { + let messages = process.new_subject() + start_worker( + connection, + dispatcher:, + messages:, + worker: "first", + order_id: "o90001", + ) + start_worker( + connection, + dispatcher:, + messages:, + worker: "second", + order_id: "o90002", + ) + + let first_release = receive_worker_ready(messages) + let second_release = receive_worker_ready(messages) + process.send(first_release, Nil) + process.send(second_release, Nil) + [ + receive_worker_finished(messages), + receive_worker_finished(messages), + ] +} + +fn start_worker( + connection: pog.Connection, + dispatcher dispatcher: factos_pog.Dispatcher(prevent_record_duplication.Event), + messages messages: process.Subject(WorkerMessage), + worker worker: String, + order_id order_id: String, +) -> process.Pid { + process.spawn(fn() { + let release = process.new_subject() + process.send(messages, WorkerReady(worker:, release:)) + let assert Ok(Nil) = process.receive(release, within: 10_000) + let result = + prevent_record_duplication.dispatch( + connection, + dispatcher, + prevent_record_duplication.PlaceOrder( + order_id:, + idempotency_token: "33333", + ), + uuid.v4_string, + ) + process.send(messages, WorkerFinished(worker:, result:)) + }) +} + +fn receive_worker_ready( + messages: process.Subject(WorkerMessage), +) -> process.Subject(Nil) { + let assert Ok(message) = process.receive(messages, within: 10_000) + let assert WorkerReady(worker: _, release:) = message + release +} + +fn receive_worker_finished( + messages: process.Subject(WorkerMessage), +) -> Result( + factos_pog.Dispatch(prevent_record_duplication.Event), + factos_pog.Error(prevent_record_duplication.Error), +) { + let assert Ok(message) = process.receive(messages, within: 10_000) + let assert WorkerFinished(worker: _, result:) = message + result +} + +fn is_accepted( + result: Result( + factos_pog.Dispatch(prevent_record_duplication.Event), + factos_pog.Error(prevent_record_duplication.Error), + ), +) -> Bool { + case result { + Ok(_) -> True + Error(_) -> False + } +} + +fn is_resubmission( + result: Result( + factos_pog.Dispatch(prevent_record_duplication.Event), + factos_pog.Error(prevent_record_duplication.Error), + ), +) -> Bool { + case result { + Error(factos_pog.DomainError(prevent_record_duplication.Resubmission)) -> + True + Ok(_) | Error(_) -> False + } +} + +fn count_token( + events: List(factos.Recorded(prevent_record_duplication.Event)), + idempotency_token: String, +) -> Int { + list.count(events, where: fn(recorded) { + let prevent_record_duplication.OrderPlaced( + order_id: _, + idempotency_token: recorded_token, + ) = recorded.event + recorded_token == idempotency_token + }) +} + +fn start_connection( + postgres_container: postgres.PostgresContainer, +) -> #(process.Pid, pog.Connection) { + let postgres.PostgresContainer(host:, port:, database:, username:, ..) = + postgres_container + let pool_name = process.new_name("prevent_record_duplication") + let config = + pog.default_config(pool_name) + |> pog.host(host) + |> pog.port(port) + |> pog.database(database) + |> pog.user(username) + |> pog.password(option.Some("postgres")) + |> pog.ssl(pog.SslDisabled) + + let assert Ok(actor.Started(pid:, ..)) = pog.start(config) + process.sleep(100) + #(pid, pog.named_connection(pool_name)) +} + +fn install_event_store(connection: pog.Connection) -> Nil { + let assert Ok(priv_directory) = application.priv_directory("factos_pog") + let assert Ok(sql) = + simplifile.read( + priv_directory <> "/dbmate/20260703000100_factos_pog_event_store.sql", + ) + let assert [up, ..] = string.split(sql, "-- migrate:down") + + up + |> string.split(";") + |> list.map(string.trim) + |> list.filter(fn(statement) { statement != "" }) + |> list.each(fn(statement) { + let assert Ok(_) = pog.query(statement) |> pog.execute(on: connection) + Nil + }) +} diff --git a/examples/prevent_record_duplication/gleam.toml b/examples/prevent_record_duplication/gleam.toml new file mode 100644 index 0000000..6620f00 --- /dev/null +++ b/examples/prevent_record_duplication/gleam.toml @@ -0,0 +1,18 @@ +name = "prevent_record_duplication" +version = "1.0.0" + +[dependencies] +factos = { path = "../.." } +factos_pog = { path = "../../backends/factos_pog" } +gleam_json = ">= 3.1.0 and < 4.0.0" +gleam_stdlib = ">= 1.0.0 and < 2.0.0" +pog = { git = "https://github.com/foxfriends/pog.git", ref = "919fd6ac96095ea11fa7c940b17eaece49cc5993" } + +[dev_dependencies] +gleam_erlang = ">= 1.0.0 and < 2.0.0" +gleam_otp = ">= 1.2.0 and < 2.0.0" +gleeunit = ">= 1.0.0 and < 2.0.0" +simplifile = ">= 2.5.0 and < 3.0.0" +testcontainer = ">= 1.0.2 and < 2.0.0" +testcontainer_formulas = ">= 1.0.0 and < 2.0.0" +youid = ">= 1.5.4 and < 2.0.0" diff --git a/examples/prevent_record_duplication/manifest.toml b/examples/prevent_record_duplication/manifest.toml new file mode 100644 index 0000000..7f06b78 --- /dev/null +++ b/examples/prevent_record_duplication/manifest.toml @@ -0,0 +1,46 @@ +# Do not manually edit this file, it is managed by Gleam. +# +# This file locks the dependency versions used, to make your build +# deterministic and to prevent unexpected versions from being included +# in your application. +# +# You should check this file into your source control repository. + +packages = [ + { name = "backoff", version = "1.1.6", build_tools = ["rebar3"], requirements = [], otp_app = "backoff", source = "hex", outer_checksum = "CF0CFFF8995FB20562F822E5CC47D8CCF664C5ECDC26A684CBE85C225F9D7C39" }, + { name = "cowl", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "cowl", source = "hex", outer_checksum = "7849E7C789D7228243A4253138FC883720A0BB44AEF406102328CADC64C3CA2B" }, + { name = "envie", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envie", source = "hex", outer_checksum = "E7EBA39310F32A40BF3EDDD7CD9C7A2BC289909983D357411C22873415BC322A" }, + { name = "exception", version = "2.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "6BDEA95248093599391C3B5DF1835C5C6A86C353C2F99CE539B450E3432FE117" }, + { name = "factos", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], source = "local", path = "../.." }, + { name = "factos_pog", version = "2.0.0", build_tools = ["gleam"], requirements = ["factos", "gleam_erlang", "gleam_otp", "gleam_stdlib", "gleam_time", "pog"], source = "local", path = "../../backends/factos_pog" }, + { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, + { name = "gleam_crypto", version = "1.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "2DE9E4EF53CF6FEE049D4F765731F7178F7A11AEFAE00EEE63BF7536B354AD3F" }, + { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, + { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, + { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, + { name = "gleam_stdlib", version = "1.0.5", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "CEE5B6C076A85B45F60C585F4316C63EC8B7127C119D5738C3958A9C4D50404E" }, + { name = "gleam_time", version = "1.8.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "533D8723774D61AD4998324F5DD1DABDCDBFABAFB9E87CB5D03C6955448FC97D" }, + { name = "gleeunit", version = "1.11.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "EC31ABA74256AEA531EDF8169931D775BBB384FED0A8A1BDC4DD9354E3E21826" }, + { name = "opentelemetry_api", version = "1.5.0", build_tools = ["rebar3", "mix"], requirements = [], otp_app = "opentelemetry_api", source = "hex", outer_checksum = "F53EC8A1337AE4A487D43AC89DA4BD3A3C99DDF576655D071DEED8B56A2D5DDA" }, + { name = "pg_types", version = "0.6.0", build_tools = ["rebar3"], requirements = [], otp_app = "pg_types", source = "hex", outer_checksum = "9949A4849DD13408FA249AB7B745E0D2DFDB9532AEE2B9722326E33CD082A778" }, + { name = "pgo", version = "0.20.0", build_tools = ["rebar3"], requirements = ["backoff", "opentelemetry_api", "pg_types"], otp_app = "pgo", source = "hex", outer_checksum = "2F11E6649CEB38E569EF56B16BE1D04874AE5B11A02867080A2817CE423C683B" }, + { name = "pog", version = "4.1.0", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_otp", "gleam_stdlib", "gleam_time", "pgo"], source = "git", repo = "https://github.com/foxfriends/pog.git", commit = "919fd6ac96095ea11fa7c940b17eaece49cc5993" }, + { name = "simplifile", version = "2.7.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "A2727627B063E87351934C7F7F008F2D1FDB16F6DE0B8C79F9E46459CFC9C164" }, + { name = "testcontainer", version = "1.0.2", build_tools = ["gleam"], requirements = ["cowl", "envie", "gleam_erlang", "gleam_json", "gleam_stdlib"], otp_app = "testcontainer", source = "hex", outer_checksum = "784768485ED2380AA543A0CC3F06F7A368B0DD209102C57E800E0A87E1D2FC81" }, + { name = "testcontainer_formulas", version = "1.0.0", build_tools = ["gleam"], requirements = ["cowl", "gleam_stdlib", "testcontainer"], otp_app = "testcontainer_formulas", source = "hex", outer_checksum = "F9A86A2F8400A0C72FE98F56EF5B3FD1CE10F0A63D968A1C57EA0087A3E5802B" }, + { name = "youid", version = "1.6.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_stdlib", "gleam_time"], otp_app = "youid", source = "hex", outer_checksum = "7A3ABA44B1B38BC2BDCB5474C5317AA372BE58DFBC649815EE08B03526DDA18D" }, +] + +[requirements] +factos = { path = "../.." } +factos_pog = { path = "../../backends/factos_pog" } +gleam_erlang = { version = ">= 1.0.0 and < 2.0.0" } +gleam_json = { version = ">= 3.1.0 and < 4.0.0" } +gleam_otp = { version = ">= 1.2.0 and < 2.0.0" } +gleam_stdlib = { version = ">= 1.0.0 and < 2.0.0" } +gleeunit = { version = ">= 1.0.0 and < 2.0.0" } +pog = { git = "https://github.com/foxfriends/pog.git", ref = "919fd6ac96095ea11fa7c940b17eaece49cc5993" } +simplifile = { version = ">= 2.5.0 and < 3.0.0" } +testcontainer = { version = ">= 1.0.2 and < 2.0.0" } +testcontainer_formulas = { version = ">= 1.0.0 and < 2.0.0" } +youid = { version = ">= 1.5.4 and < 2.0.0" } diff --git a/examples/prevent_record_duplication/src/prevent_record_duplication.gleam b/examples/prevent_record_duplication/src/prevent_record_duplication.gleam new file mode 100644 index 0000000..038f5e6 --- /dev/null +++ b/examples/prevent_record_duplication/src/prevent_record_duplication.gleam @@ -0,0 +1,136 @@ +//// Prevent record duplication with Dynamic Consistency Boundaries. +//// +//// This implements the DCB example at +//// https://dcb.events/examples/prevent-record-duplication/ using Factos and +//// PostgreSQL. + +import factos +import factos/factos_pog +import gleam/bit_array +import gleam/dynamic/decode +import gleam/json +import gleam/result +import pog + +pub type Command { + PlaceOrder(order_id: String, idempotency_token: String) +} + +pub type Event { + OrderPlaced(order_id: String, idempotency_token: String) +} + +type State { + TokenUnused + TokenUsed +} + +pub type Error { + Resubmission +} + +fn initial(command: Command) -> State { + case command { + PlaceOrder(order_id: _, idempotency_token: _) -> TokenUnused + } +} + +fn decide(state: State, command: Command) -> Result(List(Event), Error) { + case state, command { + TokenUnused, PlaceOrder(order_id:, idempotency_token:) -> + Ok([OrderPlaced(order_id:, idempotency_token:)]) + TokenUsed, PlaceOrder(order_id: _, idempotency_token: _) -> + Error(Resubmission) + } +} + +fn evolve(state: State, event: Event) -> State { + case state, event { + TokenUnused, OrderPlaced(order_id: _, idempotency_token: _) + | TokenUsed, OrderPlaced(order_id: _, idempotency_token: _) + -> TokenUsed + } +} + +pub fn codec() -> factos_pog.EventCodec(Event) { + factos_pog.codec(encode: encode_event, decode: decode_event) +} + +fn encode_event(event: Event) -> factos_pog.Proposed(Event) { + let OrderPlaced(order_id:, idempotency_token:) = event + let data = + json.object([ + #("order_id", json.string(order_id)), + #("idempotency_token", json.string(idempotency_token)), + ]) + |> json.to_string + |> bit_array.from_string + + factos_pog.new_proposed( + type_: factos.event_type("OrderPlaced"), + version: 1, + data:, + ) + |> factos_pog.with_tags(tags: [ + factos.tag("order:" <> order_id), + factos.tag("idempotency:" <> idempotency_token), + ]) +} + +fn decode_event( + stored: factos_pog.StoredEvent, +) -> Result(factos.Decoded(Event), factos_pog.DecodeError) { + case factos.event_type_name(stored.type_), stored.version { + "OrderPlaced", 1 -> { + use event <- result.try( + json.parse_bits(from: stored.data, using: event_decoder()) + |> result.replace_error(factos_pog.InvalidData), + ) + factos_pog.decoded_event(stored, event:) + } + _, _ -> Error(factos_pog.UnknownEvent) + } +} + +fn event_decoder() -> decode.Decoder(Event) { + use order_id <- decode.field("order_id", decode.string) + use idempotency_token <- decode.field("idempotency_token", decode.string) + decode.success(OrderPlaced(order_id:, idempotency_token:)) +} + +pub fn dispatcher() -> Result( + factos_pog.Dispatcher(Event), + factos_pog.DispatcherConfigurationError, +) { + factos_pog.dispatcher(codec: codec(), subscriptions: []) +} + +pub fn dispatch( + connection: pog.Connection, + dispatcher: factos_pog.Dispatcher(Event), + command: Command, + event_id: fn() -> String, +) -> Result(factos_pog.Dispatch(Event), factos_pog.Error(Error)) { + factos_pog.new_dispatch( + connection:, + stream: stream(command), + decider: factos.decider(initial: initial(command), decide:, evolve:), + dispatcher:, + ) + |> factos_pog.with_query(query(command)) + |> factos_pog.dispatch(command, event_id:) +} + +fn query(command: Command) -> factos.Query { + let PlaceOrder(order_id: _, idempotency_token:) = command + factos.query([ + factos.query_item(types: [factos.event_type("OrderPlaced")], tags: [ + factos.tag("idempotency:" <> idempotency_token), + ]), + ]) +} + +fn stream(command: Command) -> String { + let PlaceOrder(order_id:, idempotency_token: _) = command + "order-" <> order_id +} diff --git a/examples/prevent_record_duplication/test/prevent_record_duplication_test.gleam b/examples/prevent_record_duplication/test/prevent_record_duplication_test.gleam new file mode 100644 index 0000000..4ceab00 --- /dev/null +++ b/examples/prevent_record_duplication/test/prevent_record_duplication_test.gleam @@ -0,0 +1,24 @@ +import gleeunit +import prevent_record_duplication_dev + +pub type Timeout(a) { + Timeout(time: Int, function: fn() -> a) +} + +pub fn main() -> Nil { + gleeunit.main() +} + +pub fn prevent_record_duplication_example_test_() -> Timeout(Nil) { + use <- Timeout(120) + let assert Ok(result) = prevent_record_duplication_dev.run() + assert result + == prevent_record_duplication_dev.ExampleResult( + sequential_acceptances: 2, + sequential_resubmissions: 1, + concurrent_acceptances: 1, + concurrent_resubmissions: 1, + stored_orders: 3, + ) + Nil +} diff --git a/examples/unique_username/.gitignore b/examples/unique_username/.gitignore new file mode 100644 index 0000000..982745b --- /dev/null +++ b/examples/unique_username/.gitignore @@ -0,0 +1,7 @@ +*.beam +*.ez +/build +**/build +node_modules +**/node_modules +erl_crash.dump diff --git a/examples/unique_username/dev/unique_username_dev.gleam b/examples/unique_username/dev/unique_username_dev.gleam new file mode 100644 index 0000000..64abad7 --- /dev/null +++ b/examples/unique_username/dev/unique_username_dev.gleam @@ -0,0 +1,424 @@ +import factos +import factos/factos_pog +import gleam/erlang/application +import gleam/erlang/process +import gleam/io +import gleam/list +import gleam/option +import gleam/otp/actor +import gleam/string +import pog +import simplifile +import testcontainer +import testcontainer/error as testcontainer_error +import testcontainer_formulas/postgres +import unique_username +import youid/uuid + +pub type ExampleResult { + ExampleResult( + registrations: Int, + account_closures: Int, + username_changes: Int, + sequential_rejections: Int, + concurrent_acceptances: Int, + concurrent_rejections: Int, + stored_events: Int, + ) +} + +type WorkerMessage { + WorkerReady(worker: String, release: process.Subject(Nil)) + WorkerFinished( + worker: String, + result: Result( + factos_pog.Dispatch(unique_username.Event), + factos_pog.Error(unique_username.Error), + ), + ) +} + +pub fn run() -> Result(ExampleResult, testcontainer_error.Error) { + use postgres_container <- testcontainer.with_formula( + postgres.new() |> postgres.formula(), + ) + let #(pool_pid, connection) = start_connection(postgres_container) + install_event_store(connection) + let dispatcher = configured_dispatcher() + + require_registration( + connection, + dispatcher, + account_id: "a1", + username: "u1", + current_day: 0, + ) + require_claimed( + unique_username.dispatch( + connection, + dispatcher, + unique_username.RegisterAccount( + account_id: "a2", + username: "u1", + current_day: 0, + ), + uuid.v4_string, + ), + username: "u1", + ) + let assert Ok(_) = + unique_username.dispatch( + connection, + dispatcher, + unique_username.RecordAccountClosed( + account_id: "a1", + username: "u1", + recorded_day: 0, + ), + uuid.v4_string, + ) + require_claimed( + unique_username.dispatch( + connection, + dispatcher, + unique_username.RegisterAccount( + account_id: "a2", + username: "u1", + current_day: 3, + ), + uuid.v4_string, + ), + username: "u1", + ) + require_registration( + connection, + dispatcher, + account_id: "a2", + username: "u1", + current_day: 4, + ) + + require_registration( + connection, + dispatcher, + account_id: "a3", + username: "u2", + current_day: 0, + ) + let assert Ok(_) = + unique_username.dispatch( + connection, + dispatcher, + unique_username.RecordUsernameChanged( + account_id: "a3", + old_username: "u2", + new_username: "u3", + recorded_day: 0, + ), + uuid.v4_string, + ) + require_claimed( + unique_username.dispatch( + connection, + dispatcher, + unique_username.RegisterAccount( + account_id: "a4", + username: "u2", + current_day: 3, + ), + uuid.v4_string, + ), + username: "u2", + ) + require_registration( + connection, + dispatcher, + account_id: "a4", + username: "u2", + current_day: 4, + ) + require_claimed( + unique_username.dispatch( + connection, + dispatcher, + unique_username.RegisterAccount( + account_id: "a5", + username: "u3", + current_day: 4, + ), + uuid.v4_string, + ), + username: "u3", + ) + + let concurrent_results = run_concurrent_claim(connection, dispatcher) + let concurrent_acceptances = + list.count(concurrent_results, where: is_accepted) + let concurrent_rejections = list.count(concurrent_results, where: is_claimed) + assert concurrent_acceptances == 1 + assert concurrent_rejections == 1 + + let assert Ok(events) = + factos_pog.read_events_after( + connection, + query: factos.AllEvents, + after: factos.NoPosition, + limit: 100, + codec: unique_username.codec(), + ) + assert list.count(events, where: is_registration) == 5 + assert list.count(events, where: is_account_closure) == 1 + assert list.count(events, where: is_username_change) == 1 + assert list.length(events) == 7 + + process.send_exit(pool_pid) + process.sleep(100) + Ok(ExampleResult( + registrations: 5, + account_closures: 1, + username_changes: 1, + sequential_rejections: 4, + concurrent_acceptances:, + concurrent_rejections:, + stored_events: list.length(events), + )) +} + +pub fn main() -> Nil { + let assert Ok(ExampleResult( + registrations: 5, + account_closures: 1, + username_changes: 1, + sequential_rejections: 4, + concurrent_acceptances: 1, + concurrent_rejections: 1, + stored_events: 7, + )) = run() + io.println("first username claim: accepted") + io.println("claimed username: rejected") + io.println("closed username: retained through day 3") + io.println("closed username after day 3: accepted") + io.println("changed username retention: enforced") + io.println("concurrent username claim: 1 accepted, 1 rejected") + io.println("persisted events: 7") +} + +fn configured_dispatcher() -> factos_pog.Dispatcher(unique_username.Event) { + let assert Ok(dispatcher) = unique_username.dispatcher() + dispatcher +} + +fn require_registration( + connection: pog.Connection, + dispatcher: factos_pog.Dispatcher(unique_username.Event), + account_id account_id: String, + username username: String, + current_day current_day: Int, +) -> Nil { + let assert Ok(_) = + unique_username.dispatch( + connection, + dispatcher, + unique_username.RegisterAccount(account_id:, username:, current_day:), + uuid.v4_string, + ) + Nil +} + +fn require_claimed( + result: Result( + factos_pog.Dispatch(unique_username.Event), + factos_pog.Error(unique_username.Error), + ), + username username: String, +) -> Nil { + let assert Error(factos_pog.DomainError(unique_username.UsernameClaimed( + username: claimed_username, + ))) = result + assert claimed_username == username + Nil +} + +fn run_concurrent_claim( + connection: pog.Connection, + dispatcher: factos_pog.Dispatcher(unique_username.Event), +) -> List( + Result( + factos_pog.Dispatch(unique_username.Event), + factos_pog.Error(unique_username.Error), + ), +) { + let messages = process.new_subject() + start_worker( + connection, + dispatcher:, + messages:, + worker: "first", + account_id: "race-a", + ) + start_worker( + connection, + dispatcher:, + messages:, + worker: "second", + account_id: "race-b", + ) + + let first_release = receive_worker_ready(messages) + let second_release = receive_worker_ready(messages) + process.send(first_release, Nil) + process.send(second_release, Nil) + [ + receive_worker_finished(messages), + receive_worker_finished(messages), + ] +} + +fn start_worker( + connection: pog.Connection, + dispatcher dispatcher: factos_pog.Dispatcher(unique_username.Event), + messages messages: process.Subject(WorkerMessage), + worker worker: String, + account_id account_id: String, +) -> process.Pid { + process.spawn(fn() { + let release = process.new_subject() + process.send(messages, WorkerReady(worker:, release:)) + let assert Ok(Nil) = process.receive(release, within: 10_000) + let result = + unique_username.dispatch( + connection, + dispatcher, + unique_username.RegisterAccount( + account_id:, + username: "raced", + current_day: 0, + ), + uuid.v4_string, + ) + process.send(messages, WorkerFinished(worker:, result:)) + }) +} + +fn receive_worker_ready( + messages: process.Subject(WorkerMessage), +) -> process.Subject(Nil) { + let assert Ok(message) = process.receive(messages, within: 10_000) + let assert WorkerReady(worker: _, release:) = message + release +} + +fn receive_worker_finished( + messages: process.Subject(WorkerMessage), +) -> Result( + factos_pog.Dispatch(unique_username.Event), + factos_pog.Error(unique_username.Error), +) { + let assert Ok(message) = process.receive(messages, within: 10_000) + let assert WorkerFinished(worker: _, result:) = message + result +} + +fn is_accepted( + result: Result( + factos_pog.Dispatch(unique_username.Event), + factos_pog.Error(unique_username.Error), + ), +) -> Bool { + case result { + Ok(_) -> True + Error(_) -> False + } +} + +fn is_claimed( + result: Result( + factos_pog.Dispatch(unique_username.Event), + factos_pog.Error(unique_username.Error), + ), +) -> Bool { + case result { + Error(factos_pog.DomainError(unique_username.UsernameClaimed( + username: "raced", + ))) -> True + Ok(_) | Error(_) -> False + } +} + +fn is_registration(recorded: factos.Recorded(unique_username.Event)) -> Bool { + case recorded.event { + unique_username.AccountRegistered(username: _) -> True + unique_username.AccountClosed(username: _, recorded_day: _) + | unique_username.UsernameChanged( + old_username: _, + new_username: _, + recorded_day: _, + ) -> False + } +} + +fn is_account_closure( + recorded: factos.Recorded(unique_username.Event), +) -> Bool { + case recorded.event { + unique_username.AccountClosed(username: _, recorded_day: _) -> True + unique_username.AccountRegistered(username: _) + | unique_username.UsernameChanged( + old_username: _, + new_username: _, + recorded_day: _, + ) -> False + } +} + +fn is_username_change( + recorded: factos.Recorded(unique_username.Event), +) -> Bool { + case recorded.event { + unique_username.UsernameChanged( + old_username: _, + new_username: _, + recorded_day: _, + ) -> True + unique_username.AccountRegistered(username: _) + | unique_username.AccountClosed(username: _, recorded_day: _) -> False + } +} + +fn start_connection( + postgres_container: postgres.PostgresContainer, +) -> #(process.Pid, pog.Connection) { + let postgres.PostgresContainer(host:, port:, database:, username:, ..) = + postgres_container + let pool_name = process.new_name("unique_username") + let config = + pog.default_config(pool_name) + |> pog.host(host) + |> pog.port(port) + |> pog.database(database) + |> pog.user(username) + |> pog.password(option.Some("postgres")) + |> pog.ssl(pog.SslDisabled) + + let assert Ok(actor.Started(pid:, ..)) = pog.start(config) + process.sleep(100) + #(pid, pog.named_connection(pool_name)) +} + +fn install_event_store(connection: pog.Connection) -> Nil { + let assert Ok(priv_directory) = application.priv_directory("factos_pog") + let assert Ok(sql) = + simplifile.read( + priv_directory <> "/dbmate/20260703000100_factos_pog_event_store.sql", + ) + let assert [up, ..] = string.split(sql, "-- migrate:down") + + up + |> string.split(";") + |> list.map(string.trim) + |> list.filter(fn(statement) { statement != "" }) + |> list.each(fn(statement) { + let assert Ok(_) = pog.query(statement) |> pog.execute(on: connection) + Nil + }) +} diff --git a/examples/unique_username/gleam.toml b/examples/unique_username/gleam.toml new file mode 100644 index 0000000..e408f08 --- /dev/null +++ b/examples/unique_username/gleam.toml @@ -0,0 +1,18 @@ +name = "unique_username" +version = "1.0.0" + +[dependencies] +factos = { path = "../.." } +factos_pog = { path = "../../backends/factos_pog" } +gleam_json = ">= 3.1.0 and < 4.0.0" +gleam_stdlib = ">= 1.0.0 and < 2.0.0" +pog = { git = "https://github.com/foxfriends/pog.git", ref = "919fd6ac96095ea11fa7c940b17eaece49cc5993" } + +[dev_dependencies] +gleam_erlang = ">= 1.0.0 and < 2.0.0" +gleam_otp = ">= 1.2.0 and < 2.0.0" +gleeunit = ">= 1.0.0 and < 2.0.0" +simplifile = ">= 2.5.0 and < 3.0.0" +testcontainer = ">= 1.0.2 and < 2.0.0" +testcontainer_formulas = ">= 1.0.0 and < 2.0.0" +youid = ">= 1.5.4 and < 2.0.0" diff --git a/examples/unique_username/manifest.toml b/examples/unique_username/manifest.toml new file mode 100644 index 0000000..7f06b78 --- /dev/null +++ b/examples/unique_username/manifest.toml @@ -0,0 +1,46 @@ +# Do not manually edit this file, it is managed by Gleam. +# +# This file locks the dependency versions used, to make your build +# deterministic and to prevent unexpected versions from being included +# in your application. +# +# You should check this file into your source control repository. + +packages = [ + { name = "backoff", version = "1.1.6", build_tools = ["rebar3"], requirements = [], otp_app = "backoff", source = "hex", outer_checksum = "CF0CFFF8995FB20562F822E5CC47D8CCF664C5ECDC26A684CBE85C225F9D7C39" }, + { name = "cowl", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "cowl", source = "hex", outer_checksum = "7849E7C789D7228243A4253138FC883720A0BB44AEF406102328CADC64C3CA2B" }, + { name = "envie", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envie", source = "hex", outer_checksum = "E7EBA39310F32A40BF3EDDD7CD9C7A2BC289909983D357411C22873415BC322A" }, + { name = "exception", version = "2.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "6BDEA95248093599391C3B5DF1835C5C6A86C353C2F99CE539B450E3432FE117" }, + { name = "factos", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], source = "local", path = "../.." }, + { name = "factos_pog", version = "2.0.0", build_tools = ["gleam"], requirements = ["factos", "gleam_erlang", "gleam_otp", "gleam_stdlib", "gleam_time", "pog"], source = "local", path = "../../backends/factos_pog" }, + { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, + { name = "gleam_crypto", version = "1.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "2DE9E4EF53CF6FEE049D4F765731F7178F7A11AEFAE00EEE63BF7536B354AD3F" }, + { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, + { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, + { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, + { name = "gleam_stdlib", version = "1.0.5", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "CEE5B6C076A85B45F60C585F4316C63EC8B7127C119D5738C3958A9C4D50404E" }, + { name = "gleam_time", version = "1.8.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "533D8723774D61AD4998324F5DD1DABDCDBFABAFB9E87CB5D03C6955448FC97D" }, + { name = "gleeunit", version = "1.11.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "EC31ABA74256AEA531EDF8169931D775BBB384FED0A8A1BDC4DD9354E3E21826" }, + { name = "opentelemetry_api", version = "1.5.0", build_tools = ["rebar3", "mix"], requirements = [], otp_app = "opentelemetry_api", source = "hex", outer_checksum = "F53EC8A1337AE4A487D43AC89DA4BD3A3C99DDF576655D071DEED8B56A2D5DDA" }, + { name = "pg_types", version = "0.6.0", build_tools = ["rebar3"], requirements = [], otp_app = "pg_types", source = "hex", outer_checksum = "9949A4849DD13408FA249AB7B745E0D2DFDB9532AEE2B9722326E33CD082A778" }, + { name = "pgo", version = "0.20.0", build_tools = ["rebar3"], requirements = ["backoff", "opentelemetry_api", "pg_types"], otp_app = "pgo", source = "hex", outer_checksum = "2F11E6649CEB38E569EF56B16BE1D04874AE5B11A02867080A2817CE423C683B" }, + { name = "pog", version = "4.1.0", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_otp", "gleam_stdlib", "gleam_time", "pgo"], source = "git", repo = "https://github.com/foxfriends/pog.git", commit = "919fd6ac96095ea11fa7c940b17eaece49cc5993" }, + { name = "simplifile", version = "2.7.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "A2727627B063E87351934C7F7F008F2D1FDB16F6DE0B8C79F9E46459CFC9C164" }, + { name = "testcontainer", version = "1.0.2", build_tools = ["gleam"], requirements = ["cowl", "envie", "gleam_erlang", "gleam_json", "gleam_stdlib"], otp_app = "testcontainer", source = "hex", outer_checksum = "784768485ED2380AA543A0CC3F06F7A368B0DD209102C57E800E0A87E1D2FC81" }, + { name = "testcontainer_formulas", version = "1.0.0", build_tools = ["gleam"], requirements = ["cowl", "gleam_stdlib", "testcontainer"], otp_app = "testcontainer_formulas", source = "hex", outer_checksum = "F9A86A2F8400A0C72FE98F56EF5B3FD1CE10F0A63D968A1C57EA0087A3E5802B" }, + { name = "youid", version = "1.6.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_stdlib", "gleam_time"], otp_app = "youid", source = "hex", outer_checksum = "7A3ABA44B1B38BC2BDCB5474C5317AA372BE58DFBC649815EE08B03526DDA18D" }, +] + +[requirements] +factos = { path = "../.." } +factos_pog = { path = "../../backends/factos_pog" } +gleam_erlang = { version = ">= 1.0.0 and < 2.0.0" } +gleam_json = { version = ">= 3.1.0 and < 4.0.0" } +gleam_otp = { version = ">= 1.2.0 and < 2.0.0" } +gleam_stdlib = { version = ">= 1.0.0 and < 2.0.0" } +gleeunit = { version = ">= 1.0.0 and < 2.0.0" } +pog = { git = "https://github.com/foxfriends/pog.git", ref = "919fd6ac96095ea11fa7c940b17eaece49cc5993" } +simplifile = { version = ">= 2.5.0 and < 3.0.0" } +testcontainer = { version = ">= 1.0.2 and < 2.0.0" } +testcontainer_formulas = { version = ">= 1.0.0 and < 2.0.0" } +youid = { version = ">= 1.5.4 and < 2.0.0" } diff --git a/examples/unique_username/src/unique_username.gleam b/examples/unique_username/src/unique_username.gleam new file mode 100644 index 0000000..6e1a979 --- /dev/null +++ b/examples/unique_username/src/unique_username.gleam @@ -0,0 +1,325 @@ +//// Enforce globally unique usernames with Dynamic Consistency Boundaries. +//// +//// This implements the example at +//// https://dcb.events/examples/unique-username/ using Factos and PostgreSQL. +//// The source's relative `daysAgo` metadata is represented by an absolute +//// `recorded_day`, keeping retrying decisions deterministic. + +import factos +import factos/factos_pog +import gleam/bit_array +import gleam/dynamic/decode +import gleam/int +import gleam/json +import gleam/result +import pog + +const username_retention_days = 3 + +const recorded_day_key = "recorded_day" + +pub type Event { + AccountRegistered(username: String) + AccountClosed(username: String, recorded_day: Int) + UsernameChanged(old_username: String, new_username: String, recorded_day: Int) +} + +pub type Command { + RegisterAccount(account_id: String, username: String, current_day: Int) + RecordAccountClosed(account_id: String, username: String, recorded_day: Int) + RecordUsernameChanged( + account_id: String, + old_username: String, + new_username: String, + recorded_day: Int, + ) +} + +pub type Error { + UsernameClaimed(username: String) +} + +type ClaimState { + Available + Claimed + RetainedUntil(day: Int) +} + +type State { + RegisteringAccount(username: String, claim: ClaimState) + RecordingFact +} + +fn initial(command: Command) -> State { + case command { + RegisterAccount(account_id: _, username:, current_day: _) -> + RegisteringAccount(username:, claim: Available) + RecordAccountClosed(account_id: _, username: _, recorded_day: _) + | RecordUsernameChanged( + account_id: _, + old_username: _, + new_username: _, + recorded_day: _, + ) -> RecordingFact + } +} + +fn decide(state: State, command: Command) -> Result(List(Event), Error) { + case state, command { + RegisteringAccount(username: _, claim: Available), + RegisterAccount(account_id: _, username:, current_day: _) + -> Ok([AccountRegistered(username:)]) + RegisteringAccount(username: _, claim: Claimed), + RegisterAccount(account_id: _, username:, current_day: _) + -> Error(UsernameClaimed(username:)) + RegisteringAccount(username: _, claim: RetainedUntil(day:)), + RegisterAccount(account_id: _, username:, current_day:) + if current_day <= day + -> Error(UsernameClaimed(username:)) + RegisteringAccount(username: _, claim: RetainedUntil(day: _)), + RegisterAccount(account_id: _, username:, current_day: _) + -> Ok([AccountRegistered(username:)]) + RecordingFact, RecordAccountClosed(account_id: _, username:, recorded_day:) + -> Ok([AccountClosed(username:, recorded_day:)]) + RecordingFact, + RecordUsernameChanged( + account_id: _, + old_username:, + new_username:, + recorded_day:, + ) + -> Ok([UsernameChanged(old_username:, new_username:, recorded_day:)]) + _, _ -> panic as "Command executed for wrong state" + } +} + +fn evolve(state: State, event: Event) -> State { + case state, event { + RegisteringAccount(username:, claim: _), + AccountRegistered(username: event_username) + -> + case event_username == username { + True -> RegisteringAccount(username:, claim: Claimed) + False -> state + } + RegisteringAccount(username:, claim: _), + AccountClosed(username: event_username, recorded_day:) + -> + case event_username == username { + True -> + RegisteringAccount( + username:, + claim: RetainedUntil(day: recorded_day + username_retention_days), + ) + False -> state + } + RegisteringAccount(username:, claim:), + UsernameChanged(old_username:, new_username:, recorded_day:) + -> + case new_username == username, old_username == username { + True, _ -> RegisteringAccount(username:, claim: Claimed) + False, True -> + RegisteringAccount( + username:, + claim: RetainedUntil(day: recorded_day + username_retention_days), + ) + False, False -> RegisteringAccount(username:, claim:) + } + RecordingFact, AccountRegistered(username: _) + | RecordingFact, AccountClosed(username: _, recorded_day: _) + | RecordingFact, + UsernameChanged(old_username: _, new_username: _, recorded_day: _) + -> RecordingFact + } +} + +pub fn codec() -> factos_pog.EventCodec(Event) { + factos_pog.codec(encode: encode_event, decode: decode_event) +} + +fn encode_event(event: Event) -> factos_pog.Proposed(Event) { + case event { + AccountRegistered(username:) -> + proposed_event( + type_: "AccountRegistered", + data: json.object([#("username", json.string(username))]), + tags: [factos.tag("username:" <> username)], + ) + AccountClosed(username:, recorded_day:) -> + proposed_event( + type_: "AccountClosed", + data: json.object([#("username", json.string(username))]), + tags: [factos.tag("username:" <> username)], + ) + |> factos_pog.with_metadata(metadata: recorded_day_metadata(recorded_day)) + UsernameChanged(old_username:, new_username:, recorded_day:) -> + proposed_event( + type_: "UsernameChanged", + data: json.object([ + #("old_username", json.string(old_username)), + #("new_username", json.string(new_username)), + ]), + tags: [ + factos.tag("username:" <> old_username), + factos.tag("username:" <> new_username), + ], + ) + |> factos_pog.with_metadata(metadata: recorded_day_metadata(recorded_day)) + } +} + +fn proposed_event( + type_ type_name: String, + data data: json.Json, + tags tags: List(factos.Tag), +) -> factos_pog.Proposed(Event) { + factos_pog.new_proposed( + type_: factos.event_type(type_name), + version: 1, + data: data |> json.to_string |> bit_array.from_string, + ) + |> factos_pog.with_tags(tags:) +} + +fn recorded_day_metadata(recorded_day: Int) -> factos.Metadata { + factos.metadata([#(recorded_day_key, int.to_string(recorded_day))]) +} + +fn decode_event( + stored: factos_pog.StoredEvent, +) -> Result(factos.Decoded(Event), factos_pog.DecodeError) { + case factos.event_type_name(stored.type_), stored.version { + "AccountRegistered", 1 -> { + use username <- result.try(decode_payload(stored, username_decoder())) + factos_pog.decoded_event(stored, event: AccountRegistered(username:)) + } + "AccountClosed", 1 -> { + use username <- result.try(decode_payload(stored, username_decoder())) + use recorded_day <- result.try(decode_recorded_day(stored.metadata)) + factos_pog.decoded_event( + stored, + event: AccountClosed(username:, recorded_day:), + ) + } + "UsernameChanged", 1 -> { + use names <- result.try(decode_payload(stored, changed_names_decoder())) + use recorded_day <- result.try(decode_recorded_day(stored.metadata)) + factos_pog.decoded_event( + stored, + event: UsernameChanged( + old_username: names.0, + new_username: names.1, + recorded_day:, + ), + ) + } + _, _ -> Error(factos_pog.UnknownEvent) + } +} + +fn decode_payload( + stored: factos_pog.StoredEvent, + decoder: decode.Decoder(value), +) -> Result(value, factos_pog.DecodeError) { + json.parse_bits(from: stored.data, using: decoder) + |> result.replace_error(factos_pog.InvalidData) +} + +fn decode_recorded_day( + metadata: factos.Metadata, +) -> Result(Int, factos_pog.DecodeError) { + use value <- result.try( + factos.metadata_get(metadata, recorded_day_key) + |> result.replace_error(factos_pog.InvalidData), + ) + int.parse(value) |> result.replace_error(factos_pog.InvalidData) +} + +fn username_decoder() -> decode.Decoder(String) { + use username <- decode.field("username", decode.string) + decode.success(username) +} + +fn changed_names_decoder() -> decode.Decoder(#(String, String)) { + use old_username <- decode.field("old_username", decode.string) + use new_username <- decode.field("new_username", decode.string) + decode.success(#(old_username, new_username)) +} + +pub fn dispatcher() -> Result( + factos_pog.Dispatcher(Event), + factos_pog.DispatcherConfigurationError, +) { + factos_pog.dispatcher(codec: codec(), subscriptions: []) +} + +pub fn dispatch( + connection: pog.Connection, + dispatcher: factos_pog.Dispatcher(Event), + command: Command, + event_id: fn() -> String, +) -> Result(factos_pog.Dispatch(Event), factos_pog.Error(Error)) { + factos_pog.new_dispatch( + connection:, + stream: stream(command), + decider: factos.decider(initial: initial(command), decide:, evolve:), + dispatcher:, + ) + |> factos_pog.with_query(query(command)) + |> factos_pog.dispatch(command, event_id:) +} + +fn query(command: Command) -> factos.Query { + case command { + RegisterAccount(account_id: _, username:, current_day: _) + | RecordAccountClosed(account_id: _, username:, recorded_day: _) -> + factos.query([ + factos.query_item( + types: [ + factos.event_type("AccountRegistered"), + factos.event_type("AccountClosed"), + factos.event_type("UsernameChanged"), + ], + tags: [factos.tag("username:" <> username)], + ), + ]) + RecordUsernameChanged( + account_id: _, + old_username:, + new_username:, + recorded_day: _, + ) -> + factos.query([ + factos.query_item( + types: [ + factos.event_type("AccountRegistered"), + factos.event_type("AccountClosed"), + factos.event_type("UsernameChanged"), + ], + tags: [factos.tag("username:" <> old_username)], + ), + factos.query_item( + types: [ + factos.event_type("AccountRegistered"), + factos.event_type("AccountClosed"), + factos.event_type("UsernameChanged"), + ], + tags: [factos.tag("username:" <> new_username)], + ), + ]) + } +} + +fn stream(command: Command) -> String { + let account_id = case command { + RegisterAccount(account_id:, username: _, current_day: _) + | RecordAccountClosed(account_id:, username: _, recorded_day: _) + | RecordUsernameChanged( + account_id:, + old_username: _, + new_username: _, + recorded_day: _, + ) -> account_id + } + "account-" <> account_id +} diff --git a/examples/unique_username/test/unique_username_test.gleam b/examples/unique_username/test/unique_username_test.gleam new file mode 100644 index 0000000..6a5eab5 --- /dev/null +++ b/examples/unique_username/test/unique_username_test.gleam @@ -0,0 +1,26 @@ +import gleeunit +import unique_username_dev + +pub type Timeout(a) { + Timeout(time: Int, function: fn() -> a) +} + +pub fn main() -> Nil { + gleeunit.main() +} + +pub fn unique_username_example_test_() -> Timeout(Nil) { + use <- Timeout(120) + let assert Ok(result) = unique_username_dev.run() + assert result + == unique_username_dev.ExampleResult( + registrations: 5, + account_closures: 1, + username_changes: 1, + sequential_rejections: 4, + concurrent_acceptances: 1, + concurrent_rejections: 1, + stored_events: 7, + ) + Nil +} diff --git a/gleam.toml b/gleam.toml index 2d0fa5b..0ec3fec 100644 --- a/gleam.toml +++ b/gleam.toml @@ -41,3 +41,19 @@ gleam_stdlib = ">= 1.0.0 and < 2.0.0" [dev_dependencies] gleeunit = ">= 1.0.0 and < 2.0.0" + +[tools.trellis] +members = [ + "examples/course_subscriptions", + "examples/dynamic_product_price", + "examples/invoice_number", + "examples/opt_in_token", + "examples/performance", + "examples/prevent_record_duplication", + "examples/unique_username", + ".", + "backends/factos_sqlight", + "backends/factos_pog", + "backends/factos_cf", +] +max_parallel = 10 diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..56147ba --- /dev/null +++ b/mise.toml @@ -0,0 +1,15 @@ +[tasks.format] +run = "trellis run format" + +[tasks.format-check] +run = "trellis run format --check" + +[tasks.test] +run = "trellis run test" + +[tasks.check] +alias = "ci" +depends = ['format-check', 'test'] + +[tools] +gleam = "latest"