diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d5ffbe2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,31 @@ +# Changelog + +## Unreleased + +## v2.0.0 - 2026-08-30 + +- The stream and revision APIs have been removed. Recorded events now use one + globally ordered sequence position and carry an `Event` envelope containing + their payload and store-visible descriptor. +- `Query` and `QueryItem` have been replaced by `DecisionContext` and `Item`. + `NoContext` explicitly represents commands that do not read or protect prior + events. +- Event codecs now encode `Event(json.Json)` values and select a dynamic decoder + by event type and version. +- The `Model` type has been added for reusable decider and codec configuration. + Backend `Configuration` values add connections, retry policy, and + transactional subscriptions. +- Strong transactional subscriptions have been added through `Subscription`, + `subscription`, and `plan_subscription`. A subscription failure rolls back + the originating append on supported backends. +- The `factos/simulate` module has been added for deterministic + `given`/`dispatch`/`assert_events`/`assert_errors` domain scenarios without a + database. +- `factos_pog`, `factos_sqlight`, and `factos_cf` have been updated to the new + context-first model. Backend-owned stream and outbox storage has been removed. +- The DCB examples now use `factos/simulate`. The PostgreSQL performance suite + has moved to `backends/factos_pog/benchmark`. + +## v1.0.0 - 2026-08-07 + +- Initial release. diff --git a/README.md b/README.md index 5832230..af30cf0 100644 --- a/README.md +++ b/README.md @@ -1,281 +1,117 @@ # ☝️🤓 Factos -Factos provides primitives and storage backends for building event-sourced systems -in Gleam. - -The library helps with the repetitive part of event-sourced applications: - -1. read previously stored events; -2. fold them into the state needed for a decision; -3. run your domain decision function; -4. persist the newly accepted events; -5. return the committed records so your application can update views or trigger - effects. - -Factos is not a large framework. Your application still defines the commands, -events, state, errors, event encoders and decoders, read models, and side -effects. Factos gives those pieces a standard shape and gives backends a -standard way to run the read-decide-append flow safely. - -## What gets stored? - -Backends store events. - -Materialized views are not stored by Factos itself. A projection is ordinary -application code, often a pure fold over events. If you want a durable read -model, your application stores the result wherever it wants: PostgreSQL tables, -Redis, SQLite, files, or something else. - -Effect derivation is also ordinary application code. A pure function can map -recorded event envelopes to application-owned effect values. Your application -chooses whether to persist those values atomically, run best-effort work after -commit, retry delivery, or ignore effects during replay. - -So the durable state provided by the backend is: - -- the append-only event log; -- event metadata needed for reads and consistency checks. - -Everything else is application state built from that log. - -## What does `factos` provide? - -The core package is store-independent. It provides the shared types and -functions used by backends and applications: - -- `Decider`: your command decision logic as pure data. -- `DecisionContext`: the recorded facts a command must consider. -- `Context`: previously stored events folded into decision state. -- `AppendCondition`: the condition a backend must protect before appending. -- application-owned event encoder and decoder functions for the backend payload; -- `factos.Event(payload)`: a payload and its store-visible descriptor. -- `factos.Recorded(payload)`: an event envelope plus backend id and position. -- `factos.DispatchBuilder`: store-independent dispatch configuration. -- `factos.Subscription`: a dispatch-bound application callback and consistency - mode. - -Applications create these values with shared functions such as -`factos.new_event`, `factos.new_dispatch`, `factos.new_subscription`, and -`factos.with_subscriptions`. The consistency modes and dispatch error -constructors are shared as well. A concrete backend executes the dispatch -builder against storage and formats its store-specific errors. - -A decider has this shape: - -```gleam -factos.decider( - initial: TicketWindow(capacity: 100, sold: 0), - decide: decide, - evolve: evolve, -) -``` - -`evolve` folds accepted events into state: - -```gleam -fn evolve(state: State, event: Event) -> State { - let TicketWindow(capacity:, sold:) = state - case event { - TicketSold(buyer: _) -> TicketWindow(capacity:, sold: sold + 1) - } -} -``` - -`decide` takes a command and the folded state, then either rejects the command or -returns new events: - -```gleam -fn decide(state: State, command: Command) -> Result(List(Event), DomainError) { - let TicketWindow(capacity:, sold:) = state - case command { - BuyTicket(buyer:) -> - case sold < capacity { - True -> Ok([TicketSold(buyer:)]) - False -> Error(SoldOut(capacity:)) - } - } -} -``` - -You can test this without any database by destructuring the decider and folding -the relevant history directly: - -```gleam -let factos.Decider(initial:, decide:, evolve:) = ticket_decider() -let state = - [TicketSold(buyer: "renata")] - |> list.fold(initial, evolve) - -decide(state, BuyTicket(buyer: "lucy")) -``` - -## Events, commands, and command sourcing - -Factos stores events: facts that were accepted by the application. A backend row -is an event record, not a command record. - -The core package also provides command-handling helpers (`Decider`, `Context`, -and the builder created by `factos.new_dispatch`). A concrete backend's -`dispatch` function executes that shared builder. Together they provide an -opinionated way to build command processing on top of an event log: +Factos is a context-first event-sourcing library for Gleam. Applications own +their commands, events, decisions, codecs, projections, and effects. Factos +provides the shared model and storage adapters for a safe read-decide-append +flow. ```text -command + relevant previous events -> accepted new events or domain error -``` - -If you want lower-level event sourcing, you can use the same stored event log, -event encoders and decoders, projection folds, and reads without treating Factos -as a complete command framework. The command-dispatch path is a convenience for -applications that want that standard shape. - -## Decision contexts and tags - -Every dispatch builder requires a `DecisionContext`: the facts that can change -the command's answer and must therefore remain stable until append. - -Use the explicit variant that matches the rule: - -- `factos.NoContext` reads no history and permits an unconditional append; -- `factos.AllEvents` reads and protects the complete event log; -- `factos.Matching(items:)` selects facts by event type and tags. - -Backends do not inspect your event payload when selecting a context. If a -command must find facts by a payload value, expose that value as a tag when -recording the event. - -For a ticket-sale capacity rule: - -```gleam -fn sale_context() -> factos.DecisionContext { - factos.Matching(items: [ - factos.item( - types: [factos.event_type("TicketSold")], - tags: [factos.tag("event:gleamconf-2026")], - ), - ]) -} +command + relevant facts -> accepted events or domain error ``` -This tells the backend: "read the accepted ticket-sale facts for this event and -protect that same context before appending more ticket sales". +## Core model -Matching semantics are deliberately small: +- `Decider` folds relevant events and decides a command. +- `DecisionContext` describes the facts that can change the answer. +- `Model` combines a command-selected decider with its event codec. +- `Configuration` adds a backend connection, retry policy, and subscriptions. +- `Recorded` carries a committed event id, global position, and descriptor. -- items are OR-combined; -- event types inside one item are OR-combined; -- tags inside one item are AND-combined; -- empty event types match any event type; -- empty tags add no tag constraint; -- `Matching(items: [])` matches no events. +A decision context is one of: -Prefer `NoContext` over an empty `Matching` value when a command intentionally -does not depend on history. The explicit variant preserves that design decision. +- `NoContext` — read no history and append unconditionally. +- `AllEvents` — read and protect the complete event log. +- `Matching(items:)` — select by event type and tags. -## Simulate domain scenarios +Items are OR-combined. Types inside an item are OR-combined; tags are +AND-combined. -`factos/simulate` runs stateful domain scenarios against the same decision -context, append-condition, and decider semantics without a database: +## Define a model ```gleam -import factos/simulate - -fn describe_event(event: Event) -> factos.EventDescriptor { - case event { - TicketSold(buyer: _) -> - factos.EventDescriptor( - type_: factos.event_type("TicketSold"), - version: 1, - tags: [factos.tag("event:gleamconf-2026")], - metadata: factos.empty_metadata(), +let model = + factos.model( + decider: fn(command) { + factos.decider( + initial: initial(command), + decide: decide, + evolve: evolve, ) - } -} - -let store = - simulate.new(describe_event) - |> simulate.given(events: [TicketSold(buyer: "renata")]) - -let assert Ok(simulate.Commit(store:, events: [recorded])) = - simulate.dispatch( - store, - decision_context: sale_context(), - decider: ticket_decider(), - command: BuyTicket(buyer: "lucy"), + }, + encode: encode_event, + decode: decode_event, ) ``` -The returned `Store` contains the newly recorded fact, so later commands observe -it. The descriptor should adapt the same application-owned type, version, tags, -and metadata mapping used by production encoders. - -The simulator is an executable reference for core semantics, not a backend -interface. Keep encoder/decoder fidelity, storage predicate selection, -transaction rollback, retries, outboxes, and real concurrency in backend -integration tests. -Directly destructuring and folding a `Decider` remains the smaller approach when -history is already filtered and no recorded store transition is needed. - +The encoder returns `factos.Event(json.Json)`. The decoder selects a dynamic +decoder from the stored event type and version. -## How are views computed? +## Simulate scenarios -A projection is an ordinary pure application function. Fold the events with the -state and evolution logic that the read model needs: +`factos/simulate` exercises the model without a database: ```gleam -fn count_sold_tickets(events: List(Event)) -> Int { - list.fold(events, 0, fn(count, event) { - case event { - TicketSold(buyer: _) -> count + 1 - } - }) -} +simulate.new(model) +|> simulate.given([TicketSold(buyer: "renata")]) +|> simulate.dispatch( + decision_context: sale_context(), + command: BuyTicket(buyer: "lucy"), +) +|> simulate.assert_events([ + TicketSold(buyer: "renata"), + TicketSold(buyer: "lucy"), +]) +|> simulate.assert_errors([]) ``` -Run the function over events you already have: +Simulation proves domain and codec behavior. Backend transaction isolation and +concurrency require backend integration tests. + +## Dispatch with a backend ```gleam -let sold_count = count_sold_tickets(events) +let configuration = factos.configure(model, connection: connection) + +configuration +|> factos_pog.dispatch( + command, + decision_context: decision_context(command), + event_id: new_event_id, +) ``` -Or your application can read events from a backend and store the projected value -itself. Factos does not maintain a projection table automatically. Projection -folds can always be recomputed if the original events are still decodable. That -is why event encoder and decoder compatibility matters. +`factos.subscription` runs application work in an interactive backend +transaction. `factos.plan_subscription` extends an immutable transaction plan, +as used by Cloudflare D1. -The shared `factos.new_subscription` and `factos.with_subscriptions` functions -can attach an application callback to a dispatch builder. A -`factos.StrongConsistency` callback runs through the backend's transaction -connection, so projection rows it writes commit or roll back with the -originating event and are query-visible when dispatch returns successfully. -The current PostgreSQL and SQLite backends implement `factos.FireAndForget` by -starting independent asynchronous work only after commit. This is best-effort -work, not a supervised or durable delivery mechanism. +## Packages -## How are effects handled? +| Package | Storage | +| --- | --- | +| `factos` | Store-independent model and simulator | +| `factos_pog` | PostgreSQL | +| `factos_sqlight` | SQLite through Sqlight | +| `factos_cf` | Cloudflare D1 | -Effects are derived by an ordinary pure application function over a recorded -event: +The DCB examples under `examples/` use the simulator. The PostgreSQL benchmark +lives at `backends/factos_pog/benchmark`. -```gleam -pub type Effect { - AnnounceTicketSale(buyer: String, position: factos.SequencePosition) -} +## Development -fn ticket_effects(recorded: factos.Recorded(Event)) -> List(Effect) { - case recorded.event.payload { - TicketSold(buyer:) -> [ - AnnounceTicketSale(buyer:, position: recorded.position), - ] - } -} +```sh +trellis run check +trellis run test ``` -After dispatch, apply that function to the committed records: +PostgreSQL-backed tests and the benchmark use the root Compose service: -```gleam -let effects = list.flat_map(dispatch.events, ticket_effects) +```sh +docker compose up --wait -d ``` -Factos does not send the email, publish the webhook, or mark the effect as done. -It keeps derivation separate from execution so your application can choose the -durability and retry strategy. +Further reading: + +- [Core model](docs/core-model.md) +- [Event logs and command dispatch](docs/event-sourcing.md) +- [Factos and Domain-Driven Design](docs/domain-driven-design.md) +- [Changelog](CHANGELOG.md) diff --git a/backends/factos_cf/CHANGELOG.md b/backends/factos_cf/CHANGELOG.md new file mode 100644 index 0000000..71d175b --- /dev/null +++ b/backends/factos_cf/CHANGELOG.md @@ -0,0 +1 @@ +# factos_cf changelog diff --git a/backends/factos_cf/gleam.toml b/backends/factos_cf/gleam.toml index a823dbf..26a97c1 100644 --- a/backends/factos_cf/gleam.toml +++ b/backends/factos_cf/gleam.toml @@ -18,9 +18,10 @@ links = [ [dependencies] factos = { path = "../.." } -cf = { git = "https://tangled.org/renatillas.dev/cf", ref = "main" } +cf = { git = "https://tangled.org/renatillas.dev/cf", ref = "main" } gleam_javascript = ">= 1.0.0 and < 2.0.0" gleam_stdlib = ">= 1.0.0 and < 2.0.0" +gleam_json = ">= 3.1.0 and < 4.0.0" [dev_dependencies] cf_miniflare = { git = "https://tangled.org/renatillas.dev/cf", ref = "main", path = "cf_miniflare" } diff --git a/backends/factos_cf/manifest.toml b/backends/factos_cf/manifest.toml index 662e4d1..c019143 100644 --- a/backends/factos_cf/manifest.toml +++ b/backends/factos_cf/manifest.toml @@ -7,11 +7,12 @@ # You should check this file into your source control repository. packages = [ - { name = "cf", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_javascript", "gleam_stdlib"], source = "git", repo = "https://tangled.org/renatillas.dev/cf", commit = "ec3a10e176f50c3066d82e22d6cac972ac95a45e" }, - { name = "cf_miniflare", version = "1.0.0", build_tools = ["gleam"], requirements = ["cf", "gleam_javascript", "gleam_stdlib"], source = "git", repo = "https://tangled.org/renatillas.dev/cf", commit = "ec3a10e176f50c3066d82e22d6cac972ac95a45e", path = "cf_miniflare" }, - { name = "factos", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], source = "local", path = "../.." }, - { name = "gleam_javascript", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_javascript", source = "hex", outer_checksum = "EF6C77A506F026C6FB37941889477CD5E4234FCD4337FF0E9384E297CB8F97EB" }, - { name = "gleam_stdlib", version = "1.0.3", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "1F543AFBA5D33DA493E6087F4E4C4F20D899411343512686C98A8ABB2963CF22" }, + { name = "cf", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_javascript", "gleam_stdlib"], source = "git", repo = "https://tangled.org/renatillas.dev/cf", commit = "d41c475e887f21b6bcccadfb8ae9d7229eeb66e6" }, + { name = "cf_miniflare", version = "1.0.0", build_tools = ["gleam"], requirements = ["cf", "gleam_javascript", "gleam_stdlib"], source = "git", repo = "https://tangled.org/renatillas.dev/cf", commit = "d41c475e887f21b6bcccadfb8ae9d7229eeb66e6", path = "cf_miniflare" }, + { name = "factos", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], source = "local", path = "../.." }, + { name = "gleam_javascript", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_javascript", source = "hex", outer_checksum = "D542C4B4F40E942F5D3372D524419FA521A7BB92D621AF696CC286E89D882D55" }, + { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, + { name = "gleam_stdlib", version = "1.0.5", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "CEE5B6C076A85B45F60C585F4316C63EC8B7127C119D5738C3958A9C4D50404E" }, { name = "gleeunit", version = "1.11.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "EC31ABA74256AEA531EDF8169931D775BBB384FED0A8A1BDC4DD9354E3E21826" }, ] @@ -20,5 +21,6 @@ cf = { git = "https://tangled.org/renatillas.dev/cf", ref = "main" } cf_miniflare = { git = "https://tangled.org/renatillas.dev/cf", ref = "main", path = "cf_miniflare" } factos = { path = "../.." } gleam_javascript = { version = ">= 1.0.0 and < 2.0.0" } +gleam_json = { version = ">= 3.1.0 and < 4.0.0" } gleam_stdlib = { version = ">= 1.0.0 and < 2.0.0" } gleeunit = { version = ">= 1.0.0 and < 2.0.0" } diff --git a/backends/factos_cf/src/factos/factos_cf.gleam b/backends/factos_cf/src/factos/factos_cf.gleam index c40db7f..057dd8f 100644 --- a/backends/factos_cf/src/factos/factos_cf.gleam +++ b/backends/factos_cf/src/factos/factos_cf.gleam @@ -1,62 +1,110 @@ //// Cloudflare Workers D1 backend for Factos. //// -//// Accepted facts are stored in one append-only, globally ordered D1 table. -//// Event serialization remains application-owned through -//// `factos.EventCodec(event, String)`. +//// The implementation mirrors the Factos PostgreSQL backend's +//// read-decide-append flow. Strong subscriptions extend an immutable D1 +//// transaction plan. The event append, append guard, and planned mutations are +//// committed through one transactional D1 batch. //// -//// D1 operations are asynchronous, so public operations return -//// `Promise(Result(_, _))`. Each append uses one conditional -//// `insert ... select ... returning` statement. The decision-context check and -//// every accepted event therefore share the atomic statement boundary exposed by -//// D1 prepared statements. -//// -//// D1 has no interactive transaction in which arbitrary callbacks can run, and -//// a Worker execution context is not part of this backend client. Dispatch -//// rejects builders containing subscriptions before performing database IO. +//// D1 has no runtime for after-commit callbacks, so builders containing them +//// are rejected before database IO. import cf/d1 import factos -import gleam/dynamic.{type Dynamic} +import gleam/dict import gleam/dynamic/decode import gleam/int import gleam/javascript/array import gleam/javascript/promise.{type Promise} +import gleam/json import gleam/list -import gleam/pair import gleam/result import gleam/string -/// A failure produced by the D1 storage adapter. +pub type Configuration( + command, + state, + event, + delivery, + transaction, + domain_error, + subscription_error, +) { + Configuration( + model: factos.Model(command, state, event, domain_error), + connection: d1.Database, + retry_attempts: Int, + subscriptions: List( + factos.Subscription(delivery, subscription_error, transaction), + ), + ) +} + +pub fn configure( + model model: factos.Model(command, state, event, domain_error), + connection connection: d1.Database, +) -> Configuration( + command, + state, + event, + delivery, + transaction, + domain_error, + subscription_error, +) { + Configuration(model:, connection:, retry_attempts: 5, subscriptions: []) +} + +pub type Error(domain_error, subscription_error) = + factos.Error(domain_error, subscription_error, StoreError, json.DecodeError) + +/// A failure produced by D1 or by decoding a D1 result row. pub type StoreError { - /// D1 rejected a query or returned an unsuccessful run result. - D1Error(message: String) - /// A D1 row did not have the shape required by the event store. - RowDecodeError(errors: List(decode.DecodeError)) - /// Dispatch-bound subscriptions require guarantees D1 cannot provide here. - SubscriptionsNotSupported + D1Error(d1.Error) + UnexpectedResult(message: String) } type QuerySql { - QuerySql(sql: String, values: List(String)) + QuerySql(sql: String, parameters: List(String)) +} + +type PreparedEvent(event) { + PreparedEvent( + id: String, + event: factos.Event(event), + encoded_payload: json.Json, + ) +} + +/// An accepted event whose final D1 position is not available until commit. +pub type PendingRecorded(event) { + PendingRecorded(id: String, event: factos.Event(event)) +} + +type Mutation { + Mutation(sql: String, parameters: List(String)) } -type PendingEvent(event) { - PendingEvent(id: String, event: event, encoded: factos.Event(String)) +/// An immutable collection of D1 mutations to commit with an event append. +pub opaque type TransactionPlan { + TransactionPlan(mutations: List(Mutation)) } -/// Create the fresh D1 schema required by this backend. -/// -/// `factos_cf` is not published, so this schema is the clean streamless -/// bootstrap contract. Applications must run it before dispatching commands. +/// Add one prepared D1 mutation to the current strong-subscription plan. +pub fn add_mutation( + plan: TransactionPlan, + sql sql: String, + parameters parameters: List(String), +) -> TransactionPlan { + TransactionPlan(mutations: [Mutation(sql:, parameters:), ..plan.mutations]) +} + +/// Create the D1 schema used by this backend. pub fn migrate( database: d1.Database, -) -> Promise( - Result(Nil, factos.Error(domain_error, subscription_error, StoreError)), -) { +) -> Promise(Result(Nil, Error(domain_error, subscription_error))) { use _ <- promise.try_await(execute_migration( database, - " - create table if not exists factos_events ( + "create table if not exists factos_events ( position integer primary key autoincrement, id text not null unique, type text not null, @@ -64,534 +112,507 @@ pub fn migrate( tags text not null, metadata text not null, data text not null - ) - ", + )", )) use _ <- promise.try_await(execute_migration( database, - " - create index if not exists factos_events_type_position - on factos_events(type, position) - ", + "create index if not exists factos_events_position + on factos_events(position)", + )) + use _ <- promise.try_await(execute_migration( + database, + "create index if not exists factos_events_type_position + on factos_events(type, position)", + )) + use _ <- promise.try_await(execute_migration( + database, + "create view if not exists factos_append_guard(event_id) + as select id from factos_events", )) execute_migration( database, - " - create index if not exists factos_events_position - on factos_events(position) - ", + "create trigger if not exists factos_append_guard_missing + instead of insert on factos_append_guard + when not exists ( + select 1 from factos_events where id = new.event_id + ) + begin + select raise(abort, 'FACTOS_APPEND_CONDITION_FAILED'); + end", ) } -/// Execute a shared dispatch builder against D1. -/// -/// A failed decision-context condition is retried by rerunning the complete -/// read-decide-append attempt, up to the builder's configured attempt count. -/// Decider, codec, and event-id functions must therefore be deterministic and -/// side-effect free. -/// -/// Builders containing subscriptions return -/// `factos.StoreError(SubscriptionsNotSupported)` without database IO. D1 cannot -/// run strong callbacks inside the conditional append statement, and this -/// backend does not own the Worker execution context required for durable -/// post-response work. +/// Dispatch a command and atomically append its accepted events. pub fn dispatch( - builder: factos.DispatchBuilder( + configuration: Configuration( command, state, event, - String, + PendingRecorded(event), + TransactionPlan, domain_error, subscription_error, - d1.Database, ), - command: command, + command command: command, + decision_context decision_context: factos.DecisionContext, event_id event_id: fn() -> String, ) -> Promise( - Result( - factos.Dispatch(event), - factos.Error(domain_error, subscription_error, StoreError), - ), + Result(factos.Dispatch(event), Error(domain_error, subscription_error)), ) { - let factos.DispatchBuilder( - connection: database, - decision_context:, - decider:, - codec:, - retry_attempts:, - subscriptions:, - ) = builder + let Configuration(model:, connection:, retry_attempts:, subscriptions:) = + configuration + let factos.Model(decider:, encode:, decode: decode_event) = model + let decider = decider(command) - case subscriptions { - [] -> - dispatch_with_retries( - database, - decision_context, - decider, - codec, - command, - event_id, - retry_attempts, - ) - [_, ..] -> - promise.resolve(Error(factos.StoreError(SubscriptionsNotSupported))) - } + dispatch_attempt( + connection, + decision_context, + decider, + encode, + decode_event, + command, + event_id, + retry_attempts, + subscriptions, + ) } @internal pub fn read( database: d1.Database, - decision_context decision_context: factos.DecisionContext, - decider decider: factos.Decider(command, state, event, domain_error), - codec codec: factos.EventCodec(event, String), + decision_context: factos.DecisionContext, + decider: factos.Decider(command, state, event, domain_error), + decode_event: fn(factos.EventType, Int) -> Result(decode.Decoder(event), Nil), ) -> Promise( - Result( - factos.Context(event, state), - factos.Error(domain_error, subscription_error, StoreError), - ), + Result(factos.Context(event, state), Error(domain_error, subscription_error)), ) { - use events <- promise.map_try(read_matching_events( + use events <- promise.try_await(read_matching_events( database, decision_context, - codec, + decode_event, )) - let factos.Decider(initial:, evolve:, ..) = decider let position = factos.highest_recorded_position(events) - Ok(factos.Context( - decision_context:, - state: factos.evolve_recorded(initial:, events:, evolve:), - events:, - position:, - append_condition: factos.FailIfEventsMatch( + promise.resolve( + Ok(factos.Context( decision_context:, - after: position, - ), - )) -} - -/// Render the backend-specific error carried by `factos.StoreError`. -pub fn store_error_to_string(error: StoreError) -> String { - case error { - D1Error(message:) -> "D1 error: " <> message - RowDecodeError(errors:) -> - "D1 row decode error: " - <> { errors |> list.map(decode_error_to_string) |> string.join(", ") } - SubscriptionsNotSupported -> - "dispatch subscriptions are not supported by the D1 backend" - } -} - -fn execute_migration( - database: d1.Database, - sql: String, -) -> Promise( - Result(Nil, factos.Error(domain_error, subscription_error, StoreError)), -) { - d1.prepare(database, sql) - |> d1.run - |> promise.map(fn(query_result) { - query_result - |> result.map(fn(_) { Nil }) - |> result.map_error(d1_error) - }) + state: factos.evolve_recorded(decider, events:), + events:, + position:, + append_condition: factos.FailIfEventsMatch( + decision_context:, + after: position, + ), + )), + ) } -fn dispatch_with_retries( +@internal +pub fn read_after( database: d1.Database, decision_context: factos.DecisionContext, - decider: factos.Decider(command, state, event, domain_error), - codec: factos.EventCodec(event, String), - command: command, - event_id: fn() -> String, - attempts_remaining: Int, + after: factos.SequencePosition, + limit: Int, + decode_event: fn(factos.EventType, Int) -> Result(decode.Decoder(event), Nil), ) -> Promise( - Result( - factos.Dispatch(event), - factos.Error(domain_error, subscription_error, StoreError), - ), + Result(List(factos.Recorded(event)), Error(domain_error, subscription_error)), ) { - use dispatch_result <- promise.await(dispatch_once( - database, - decision_context, - decider, - codec, - command, - event_id, - )) + case limit <= 0 { + True -> promise.resolve(Ok([])) + False -> { + let QuerySql(where_sql, parameters) = + events_after_where_sql(decision_context, after) + d1.prepare( + database, + "select position, id, type, version, tags, metadata, data + from factos_events " <> where_sql <> " + order by position + limit cast(? as integer)", + ) + |> d1.bind(list.append(parameters, [int.to_string(limit)])) + |> d1.returning(stored_row_decoder(decode_event)) + |> d1.run + |> decode_stored_rows + } + } +} - case dispatch_result { - Error(factos.AppendConditionFailed(condition)) -> - case attempts_remaining > 1 { - True -> - dispatch_with_retries( - database, - decision_context, - decider, - codec, - command, - event_id, - attempts_remaining - 1, - ) - False -> promise.resolve(Error(factos.AppendConditionFailed(condition))) - } - Ok(dispatch) -> promise.resolve(Ok(dispatch)) - Error(factos.DomainError(error)) -> - promise.resolve(Error(factos.DomainError(error))) - Error(factos.SubscriptionError(error: error)) -> - promise.resolve(Error(factos.SubscriptionError(error: error))) - Error(factos.StoreError(error)) -> - promise.resolve(Error(factos.StoreError(error))) - Error(factos.DecodeError(error)) -> - promise.resolve(Error(factos.DecodeError(error))) +/// Render the backend-specific error carried by `factos.StoreError`. +pub fn store_error_to_string(error: StoreError) -> String { + case error { + D1Error(error) -> "D1 error: " <> d1_error_to_string(error) + UnexpectedResult(message:) -> "unexpected D1 result: " <> message } } -fn dispatch_once( +fn dispatch_attempt( database: d1.Database, decision_context: factos.DecisionContext, decider: factos.Decider(command, state, event, domain_error), - codec: factos.EventCodec(event, String), + encode: fn(event) -> factos.Event(json.Json), + decode_event: fn(factos.EventType, Int) -> Result(decode.Decoder(event), Nil), command: command, event_id: fn() -> String, -) -> Promise( - Result( - factos.Dispatch(event), - factos.Error(domain_error, subscription_error, StoreError), + attempts_remaining: Int, + subscriptions: List( + factos.Subscription( + PendingRecorded(event), + subscription_error, + TransactionPlan, + ), ), +) -> Promise( + Result(factos.Dispatch(event), Error(domain_error, subscription_error)), ) { use context <- promise.try_await(read( database, - decision_context:, - decider:, - codec:, + decision_context, + decider, + decode_event, )) case factos.decide_context(context, command, decider) { Error(error) -> promise.resolve(Error(factos.DomainError(error))) - Ok(#(context, events)) -> - append_events(database, events, codec, event_id, context.append_condition) - } -} - -fn append_events( - database: d1.Database, - events: List(event), - codec: factos.EventCodec(event, String), - event_id: fn() -> String, - condition: factos.AppendCondition, -) -> Promise( - Result( - factos.Dispatch(event), - factos.Error(domain_error, subscription_error, StoreError), - ), -) { - case events { - [] -> + Ok([]) -> promise.resolve( Ok(factos.Dispatch(position: factos.NoPosition, events: [])), ) - [_, ..] -> { - let factos.EventCodec(encode:, ..) = codec + Ok([_, ..] as events) -> { + let prepared_events = prepare_events(events, encode, event_id) let pending_events = - list.map(events, fn(event) { - PendingEvent(id: event_id(), event:, encoded: encode(event)) + list.map(prepared_events, fn(event) { + PendingRecorded(id: event.id, event: event.event) }) - let #(sql, values) = append_sql(pending_events, condition) - let event_statement = d1.prepare(database, sql) |> d1.bind(values) - - d1.batch(database, [event_statement]) - |> decode_batch_result(pending_events, condition) + case + plan_strong_subscriptions( + TransactionPlan(mutations: []), + subscriptions, + pending_events, + ) + { + Error(error) -> promise.resolve(Error(factos.SubscriptionError(error))) + Ok(plan) -> { + use append_result <- promise.await(insert_event_batch( + database, + prepared_events, + context.append_condition, + plan, + )) + case append_result { + Error(factos.AppendConditionFailed(_)) if attempts_remaining > 1 -> + dispatch_attempt( + database, + decision_context, + decider, + encode, + decode_event, + command, + event_id, + attempts_remaining - 1, + subscriptions, + ) + result -> promise.resolve(result) + } + } + } } } } -fn decode_batch_result( - batch_result: Promise(Result(array.Array(d1.RunResult), String)), - events: List(PendingEvent(event)), - condition: factos.AppendCondition, -) -> Promise( - Result( - factos.Dispatch(event), - factos.Error(domain_error, subscription_error, StoreError), +fn plan_strong_subscriptions( + plan: TransactionPlan, + subscriptions: List( + factos.Subscription( + PendingRecorded(event), + subscription_error, + TransactionPlan, + ), ), -) { - use query_result <- promise.map(batch_result) - use run_results <- result.try(query_result |> result.map_error(d1_error)) - use first_result <- result.try( - list.first(array.to_list(run_results)) - |> result.replace_error(d1_error("no event insert result in batch")), - ) - let d1.RunResult(success:, results: event_rows, ..) = first_result - use _ <- result.try(case success { - True -> Ok(Nil) - False -> Error(d1_error("event insert failed")) - }) - use positions <- result.try(decode_append_positions(event_rows)) - - case list.length(positions) == list.length(events) { - False -> Error(factos.AppendConditionFailed(condition)) - True -> { - let recorded_events = recorded_append_rows(events, positions) - Ok(factos.Dispatch( - position: factos.highest_recorded_position(recorded_events), - events: recorded_events, - )) + events: List(PendingRecorded(event)), +) -> Result(TransactionPlan, subscription_error) { + case subscriptions { + [] -> Ok(plan) + [factos.Subscription(apply:), ..remaining] -> { + use plan <- result.try(plan_subscription_events(plan, apply, events)) + plan_strong_subscriptions(plan, remaining, events) } } } -fn recorded_append_rows( - events: List(PendingEvent(event)), - positions: List(Int), -) -> List(factos.Recorded(event)) { - case events, positions { - [], _ -> [] - _, [] -> [] - [pending, ..remaining_events], [position, ..remaining_positions] -> { - let PendingEvent(id:, event:, encoded:) = pending - let factos.Event(descriptor:, ..) = encoded - [ - factos.Recorded( - id:, - position: factos.SequencePosition(position), - event:, - descriptor:, - ), - ..recorded_append_rows(remaining_events, remaining_positions) - ] +fn plan_subscription_events( + plan: TransactionPlan, + apply: fn(TransactionPlan, PendingRecorded(event)) -> + Result(TransactionPlan, subscription_error), + events: List(PendingRecorded(event)), +) -> Result(TransactionPlan, subscription_error) { + case events { + [] -> Ok(plan) + [event, ..remaining] -> { + use plan <- result.try(apply(plan, event)) + plan_subscription_events(plan, apply, remaining) } } } -fn read_matching_events( +fn prepare_events( + events: List(event), + encode: fn(event) -> factos.Event(json.Json), + event_id: fn() -> String, +) -> List(PreparedEvent(event)) { + case events { + [] -> [] + [payload, ..remaining] -> { + let encoded = encode(payload) + let prepared = + PreparedEvent( + id: event_id(), + event: factos.Event(payload:, descriptor: encoded.descriptor), + encoded_payload: encoded.payload, + ) + [prepared, ..prepare_events(remaining, encode, event_id)] + } + } +} + +fn insert_event_batch( database: d1.Database, - decision_context: factos.DecisionContext, - codec: factos.EventCodec(event, String), + events: List(PreparedEvent(event)), + condition: factos.AppendCondition, + plan: TransactionPlan, ) -> Promise( - Result( - List(factos.Recorded(event)), - factos.Error(domain_error, subscription_error, StoreError), - ), + Result(factos.Dispatch(event), Error(domain_error, subscription_error)), ) { - let #(where_sql, parameters) = query_to_sql(decision_context) - - d1.prepare(database, "select position, id, type, version, tags, metadata, data - from factos_events " <> where_sql <> " order by position") - |> d1.bind(parameters) - |> d1.raw - |> promise.map(fn(query_result) { - use rows <- result.try(query_result |> result.map_error(d1_error)) - decode_rows(rows, codec) + let QuerySql(condition_sql, condition_parameters) = + append_condition_to_sql(condition) + let rows = list.index_map(events, proposed_event_sql) + let event_sql = "with proposed_events ( + id, type, version, tags, metadata, data, ordinality + ) as (values " <> string.join( + list.map(rows, fn(row) { row.sql }), + with: ", ", + ) <> ") + insert into factos_events (id, type, version, tags, metadata, data) + select id, type, version, tags, metadata, data + from proposed_events + where " <> condition_sql <> " + order by ordinality + returning position, id" + let event_parameters = + list.append( + list.flat_map(rows, fn(row) { row.parameters }), + condition_parameters, + ) + let event_statement = + d1.prepare(database, event_sql) + |> d1.bind(event_parameters) + let guard_statement = + d1.prepare( + database, + "insert into factos_append_guard(event_id) values " + <> guard_placeholders(list.length(events)), + ) + |> d1.bind(list.map(events, fn(event) { event.id })) + let mutation_statements = + plan.mutations + |> list.reverse + |> list.map(fn(mutation) { + d1.prepare(database, mutation.sql) + |> d1.bind(mutation.parameters) + }) + + d1.batch(database, [event_statement, guard_statement, ..mutation_statements]) + |> promise.map(fn(batch_result) { + case batch_result { + Error(error) -> + case append_guard_failed(error) { + True -> Error(factos.AppendConditionFailed(condition)) + False -> Error(d1_error(error)) + } + Ok([event_result, _, ..]) -> { + let d1.RunResult(success:, results:, ..) = event_result + use _ <- result.try(case success { + True -> Ok(Nil) + False -> Error(unexpected_result("event insert was unsuccessful")) + }) + use inserted <- result.try( + results + |> array.to_list + |> list.try_map(decode.run(_, inserted_event_decoder())) + |> result.map_error(fn(errors) { d1_error(d1.DecodeErrors(errors)) }), + ) + decode_inserted_events(events, inserted, condition) + } + Ok(_) -> Error(unexpected_result("D1 batch omitted append results")) + } }) } -fn decode_rows( - rows: array.Array(array.Array(Dynamic)), - codec: factos.EventCodec(event, String), -) -> Result( - List(factos.Recorded(event)), - factos.Error(domain_error, subscription_error, StoreError), -) { - rows - |> array.to_list - |> list.try_map(fn(row) { decode_row(row, codec) }) -} - -fn decode_row( - row: array.Array(Dynamic), - codec: factos.EventCodec(event, String), -) -> Result( - factos.Recorded(event), - factos.Error(domain_error, subscription_error, StoreError), -) { - use stored <- result.try(decode_stored_event(row)) - let factos.EventCodec(decode: decode_event, ..) = codec - use event <- result.try( - decode_event(stored) |> result.map_error(factos.DecodeError), +fn proposed_event_sql(event: PreparedEvent(event), index: Int) -> QuerySql { + QuerySql( + sql: "(?, ?, cast(? as integer), ?, ?, ?, cast(? as integer))", + parameters: [ + event.id, + event_type_name(event.event.descriptor.type_), + int.to_string(event.event.descriptor.version), + tags_to_json(event.event.descriptor.tags), + metadata_to_json(event.event.descriptor.metadata), + json.to_string(event.encoded_payload), + int.to_string(index), + ], ) - let factos.Recorded(id:, position:, descriptor:, ..) = stored - - Ok(factos.Recorded(id:, position:, event:, descriptor:)) } -fn decode_stored_event( - row: array.Array(Dynamic), -) -> Result( - factos.Recorded(String), - factos.Error(domain_error, subscription_error, StoreError), -) { - use position <- result.try(decode_int_field(row, 0)) - use id <- result.try(decode_string_field(row, 1)) - use type_name <- result.try(decode_string_field(row, 2)) - use version <- result.try(decode_int_field(row, 3)) - use tags <- result.try(decode_string_field(row, 4)) - use metadata <- result.try(decode_string_field(row, 5)) - use data <- result.try(decode_string_field(row, 6)) - - Ok(factos.Recorded( - id:, - position: factos.SequencePosition(position), - event: data, - descriptor: factos.EventDescriptor( - type_: factos.event_type(type_name), - version:, - tags: tags_from_text(tags), - metadata: metadata_from_text(metadata), - ), - )) -} - -fn decode_append_positions( - rows: array.Array(Dynamic), -) -> Result( - List(Int), - factos.Error(domain_error, subscription_error, StoreError), -) { - rows - |> array.to_list - |> list.try_map(fn(row) { - decode.run(row, append_position_decoder()) - |> result.map_error(row_decode_error) - }) -} - -fn append_position_decoder() -> decode.Decoder(Int) { +fn inserted_event_decoder() -> decode.Decoder(#(Int, String)) { use position <- decode.field("position", decode.int) - decode.success(position) + use id <- decode.field("id", decode.string) + decode.success(#(position, id)) } -fn decode_int_field( - row: array.Array(Dynamic), - index: Int, -) -> Result(Int, factos.Error(domain_error, subscription_error, StoreError)) { - use value <- result.try( - array.get(row, index) - |> result.replace_error(row_decode_error([])), - ) - decode.run(value, decode.int) - |> result.map_error(row_decode_error) +fn decode_inserted_events( + events: List(PreparedEvent(event)), + inserted: List(#(Int, String)), + condition: factos.AppendCondition, +) -> Result(factos.Dispatch(event), Error(domain_error, subscription_error)) { + case inserted { + [] -> Error(factos.AppendConditionFailed(condition)) + [_, ..] -> + case list.length(inserted) == list.length(events) { + False -> + Error(unexpected_result("event insert returned a partial batch")) + True -> { + let positions = + inserted + |> list.map(fn(row) { #(row.1, row.0) }) + |> dict.from_list + use recorded_events <- result.try( + list.try_map(events, fn(event) { + use position <- result.try( + dict.get(positions, event.id) + |> result.map_error(fn(_) { + unexpected_result( + "event insert did not return position for " <> event.id, + ) + }), + ) + Ok(factos.Recorded( + id: event.id, + position: factos.SequencePosition(position), + event: event.event, + )) + }), + ) + Ok(factos.Dispatch( + position: factos.highest_recorded_position(recorded_events), + events: recorded_events, + )) + } + } + } } -fn decode_string_field( - row: array.Array(Dynamic), - index: Int, -) -> Result(String, factos.Error(domain_error, subscription_error, StoreError)) { - use value <- result.try( - array.get(row, index) - |> result.replace_error(row_decode_error([])), +fn append_condition_to_sql(condition: factos.AppendCondition) -> QuerySql { + let factos.FailIfEventsMatch(decision_context:, after:) = condition + let QuerySql(where_sql, parameters) = + events_after_where_sql(decision_context, after) + QuerySql( + sql: "not exists (select 1 from factos_events " <> where_sql <> " limit 1)", + parameters:, ) - decode.run(value, decode.string) - |> result.map_error(row_decode_error) } -fn query_to_sql( +fn read_matching_events( + database: d1.Database, decision_context: factos.DecisionContext, -) -> #(String, List(String)) { - case decision_context { - factos.AllEvents -> #("", []) - factos.Matching(items: []) | factos.NoContext -> #("where 1 = 0", []) - factos.Matching(items: [_, ..] as items) -> { - let built_items = list.map(items, query_item_to_sql) - let where_sql = - built_items - |> list.map(pair.first) - |> string.join(" or ") - let parameters = built_items |> list.flat_map(pair.second) - #("where " <> where_sql, parameters) - } - } + decode_event: fn(factos.EventType, Int) -> Result(decode.Decoder(event), Nil), +) -> Promise( + Result(List(factos.Recorded(event)), Error(domain_error, subscription_error)), +) { + let QuerySql(where_sql, parameters) = query_to_sql(decision_context) + d1.prepare(database, "select position, id, type, version, tags, metadata, data + from factos_events " <> where_sql <> " order by position") + |> d1.bind(parameters) + |> d1.returning(stored_row_decoder(decode_event)) + |> d1.run + |> decode_stored_rows } -fn query_item_to_sql(item: factos.Item) -> #(String, List(String)) { - let factos.Item(types:, tags:) = item - let #(types_sql, type_parameters) = types_to_sql(types) - let #(tags_sql, tag_parameters) = tags_to_sql(tags) +fn decode_stored_rows( + query: Promise( + Result(List(Result(row, Error(domain_error, subscription_error))), d1.Error), + ), +) -> Promise(Result(List(row), Error(domain_error, subscription_error))) { + query + |> promise.map(fn(query_result) { + query_result + |> result.map_error(d1_error) + |> result.try(result.all) + }) +} - #( - "(" <> types_sql <> " and " <> tags_sql <> ")", - list.append(type_parameters, tag_parameters), +fn stored_row_decoder( + decode_event: fn(factos.EventType, Int) -> Result(decode.Decoder(event), Nil), +) -> decode.Decoder( + Result(factos.Recorded(event), Error(domain_error, subscription_error)), +) { + use position <- decode.field("position", decode.int) + use id <- decode.field("id", decode.string) + use type_ <- decode.field( + "type", + decode.string |> decode.map(factos.EventType), ) -} + use version <- decode.field("version", decode.int) + use tags <- decode.field("tags", tags_column_decoder()) + use metadata <- decode.field("metadata", metadata_column_decoder()) + use payload <- decode.field("data", { + use encoded <- decode.then(decode.string) + case decode_event(type_, version) { + Error(Nil) -> decode.success(Error(factos.InvalidSchema(type_, version))) + Ok(decoder) -> + case json.parse(encoded, decoder) { + Ok(payload) -> decode.success(Ok(payload)) + Error(error) -> decode.success(Error(factos.DecodeError(error))) + } + } + }) -fn types_to_sql(types: List(factos.EventType)) -> #(String, List(String)) { - case types { - [] -> #("1 = 1", []) - [_, ..] -> #( - "type in (" <> placeholders(list.length(types)) <> ")", - list.map(types, factos.event_type_name), - ) + case payload { + Error(error) -> decode.success(Error(error)) + Ok(payload) -> + decode.success( + Ok(factos.Recorded( + id:, + position: factos.SequencePosition(position), + event: factos.Event( + payload:, + descriptor: factos.EventDescriptor( + type_:, + version:, + tags:, + metadata:, + ), + ), + )), + ) } } -fn tags_to_sql(tags: List(factos.Tag)) -> #(String, List(String)) { - case tags { - [] -> #("1 = 1", []) - [_, ..] -> #( - "(" - <> string.join( - list.repeat( - "instr(tags, char(10) || ? || char(10)) > 0", - list.length(tags), - ), - with: " and ", +fn query_to_sql(decision_context: factos.DecisionContext) -> QuerySql { + case decision_context { + factos.AllEvents -> QuerySql(sql: "", parameters: []) + factos.Matching(items: []) | factos.NoContext -> + QuerySql(sql: "where 1 = 0", parameters: []) + factos.Matching(items: [_, ..] as items) -> { + let built = list.map(items, query_item_to_sql) + QuerySql( + sql: "where " + <> string.join(list.map(built, fn(item) { item.sql }), with: " or "), + parameters: list.flat_map(built, fn(item) { item.parameters }), ) - <> ")", - list.map(tags, factos.tag_value), - ) + } } } -fn append_sql( - events: List(PendingEvent(event)), - condition: factos.AppendCondition, -) -> #(String, List(String)) { - let rows = list.map(events, append_select_sql(_, condition)) - let sql = - "insert into factos_events (id, type, version, tags, metadata, data) " - <> string.join(list.map(rows, fn(row) { row.0 }), with: " union all ") - <> " returning position" - let values = rows |> list.flat_map(fn(row) { row.1 }) - #(sql, values) -} - -fn append_select_sql( - pending: PendingEvent(event), - condition: factos.AppendCondition, -) -> #(String, List(String)) { - let PendingEvent(id:, encoded:, ..) = pending - let factos.Event( - payload: data, - descriptor: factos.EventDescriptor(type_:, version:, tags:, metadata:), - ) = encoded - let #(condition_sql, condition_values) = append_condition_sql(condition) - - #( - "select ?, ?, cast(? as integer), ?, ?, ? where " <> condition_sql, - list.append( - [ - id, - factos.event_type_name(type_), - int.to_string(version), - tags_to_text(tags), - metadata_to_text(metadata), - data, - ], - condition_values, - ), - ) -} - -fn append_condition_sql( - condition: factos.AppendCondition, -) -> #(String, List(String)) { - let factos.FailIfEventsMatch(decision_context:, after:) = condition - let QuerySql(sql:, values:) = - matching_events_after_sql(decision_context, after) - #("not exists (" <> sql <> ")", values) -} - -fn matching_events_after_sql( +fn events_after_where_sql( decision_context: factos.DecisionContext, after: factos.SequencePosition, ) -> QuerySql { @@ -602,125 +623,169 @@ fn matching_events_after_sql( case decision_context { factos.AllEvents -> - QuerySql( - sql: "select 1 from factos_events where position > cast(? as integer) limit 1", - values: [int.to_string(after_position)], - ) + QuerySql(sql: "where position > cast(? as integer)", parameters: [ + int.to_string(after_position), + ]) factos.Matching(items: []) | factos.NoContext -> - QuerySql(sql: "select 1 from factos_events where 1 = 0", values: []) + QuerySql(sql: "where 1 = 0", parameters: []) factos.Matching(items: [_, ..] as items) -> { - let item_sql = list.map(items, query_item_sql) + let built = list.map(items, query_item_to_sql) QuerySql( - sql: "select 1 from factos_events where position > cast(? as integer) and (" - <> string.join( - list.map(item_sql, fn(item) { item.sql }), - with: " or ", - ) - <> ") limit 1", - values: [ + sql: "where position > cast(? as integer) and (" + <> string.join(list.map(built, fn(item) { item.sql }), with: " or ") + <> ")", + parameters: [ int.to_string(after_position), - ..list.flat_map(item_sql, fn(item) { item.values }) + ..list.flat_map(built, fn(item) { item.parameters }) ], ) } } } -fn query_item_sql(item: factos.Item) -> QuerySql { +fn query_item_to_sql(item: factos.Item) -> QuerySql { let factos.Item(types:, tags:) = item - let type_sql = case types { - [] -> QuerySql(sql: "1 = 1", values: []) + let QuerySql(type_sql, type_parameters) = types_to_sql(types) + let QuerySql(tag_sql, tag_parameters) = tags_to_sql(tags) + QuerySql( + sql: "(" <> type_sql <> " and " <> tag_sql <> ")", + parameters: list.append(type_parameters, tag_parameters), + ) +} + +fn types_to_sql(types: List(factos.EventType)) -> QuerySql { + case types { + [] -> QuerySql(sql: "1 = 1", parameters: []) [_, ..] -> QuerySql( sql: "type in (" <> placeholders(list.length(types)) <> ")", - values: list.map(types, factos.event_type_name), + parameters: list.map(types, event_type_name), ) } - let tag_sql = case tags { - [] -> QuerySql(sql: "1 = 1", values: []) +} + +fn tags_to_sql(tags: List(factos.Tag)) -> QuerySql { + case tags { + [] -> QuerySql(sql: "1 = 1", parameters: []) [_, ..] -> QuerySql( - sql: string.join( - list.repeat("instr(tags, ?) > 0", list.length(tags)), + sql: "(" + <> string.join( + list.repeat( + "exists ( + select 1 from json_each(factos_events.tags) as event_tag + where event_tag.value = ? + )", + list.length(tags), + ), with: " and ", - ), - values: list.map(tags, fn(tag) { "\n" <> factos.tag_value(tag) <> "\n" }), + ) + <> ")", + parameters: list.map(tags, tag_text), ) } - - QuerySql( - sql: "(" <> type_sql.sql <> " and " <> tag_sql.sql <> ")", - values: list.append(type_sql.values, tag_sql.values), - ) } fn placeholders(count: Int) -> String { list.repeat("?", count) |> string.join(with: ", ") } -fn tags_to_text(tags: List(factos.Tag)) -> String { - case tags { - [] -> "" - [_, ..] -> - "\n" - <> { tags |> list.map(factos.tag_value) |> string.join(with: "\n") } - <> "\n" +fn guard_placeholders(count: Int) -> String { + list.repeat("(?)", count) |> string.join(with: ", ") +} + +fn tags_to_json(tags: List(factos.Tag)) -> String { + tags + |> list.map(tag_text) + |> json.array(json.string) + |> json.to_string +} + +fn tags_column_decoder() -> decode.Decoder(List(factos.Tag)) { + use tags <- decode.then(decode.string) + case + json.parse( + tags, + using: decode.string |> decode.map(factos.Tag) |> decode.list, + ) + { + Ok(tags) -> decode.success(tags) + Error(_) -> decode.failure([], "a JSON array of string tags") } } -fn tags_from_text(tags: String) -> List(factos.Tag) { - case string.is_empty(tags) { - True -> [] - False -> - tags - |> string.split(on: "\n") - |> list.filter(fn(tag) { !string.is_empty(tag) }) - |> list.map(factos.tag) +fn metadata_to_json(metadata: factos.Metadata) -> String { + factos.metadata_to_json(metadata) |> json.to_string +} + +fn event_type_name(type_: factos.EventType) -> String { + let factos.EventType(name) = type_ + name +} + +fn tag_text(tag: factos.Tag) -> String { + let factos.Tag(value) = tag + value +} + +fn metadata_column_decoder() -> decode.Decoder(factos.Metadata) { + use metadata <- decode.then(decode.string) + case json.parse(metadata, decode.dict(decode.string, decode.string)) { + Ok(entries) -> entries |> dict.to_list |> factos.metadata |> decode.success + Error(_) -> + decode.failure(factos.empty_metadata(), "a JSON metadata object") } } -fn metadata_to_text(metadata: factos.Metadata) -> String { - metadata - |> factos.metadata_entries - |> list.map(fn(entry) { entry.0 <> "=" <> entry.1 }) - |> string.join(with: "\n") +fn d1_error(error: d1.Error) -> Error(domain_error, subscription_error) { + factos.StoreError(D1Error(error)) } -fn metadata_from_text(metadata: String) -> factos.Metadata { - case string.is_empty(metadata) { - True -> factos.empty_metadata() - False -> - metadata - |> string.split(on: "\n") - |> list.filter_map(fn(entry) { - case string.split(entry, on: "=") { - [key, value] -> Ok(#(key, value)) - _ -> Error(Nil) - } - }) - |> factos.metadata +fn execute_migration( + database: d1.Database, + sql: String, +) -> Promise(Result(Nil, Error(domain_error, subscription_error))) { + d1.prepare(database, sql) + |> d1.run + |> promise.map(fn(query_result) { + query_result + |> result.map(fn(_) { Nil }) + |> result.map_error(d1_error) + }) +} + +fn append_guard_failed(error: d1.Error) -> Bool { + case error { + d1.BatchError(message) -> + string.contains(message, "FACTOS_APPEND_CONDITION_FAILED") + d1.RunError(_) | d1.DecodeErrors(_) | d1.StatementResultMismatch -> False } } -fn d1_error( +fn unexpected_result( message: String, -) -> factos.Error(domain_error, subscription_error, StoreError) { - factos.StoreError(D1Error(message:)) +) -> Error(domain_error, subscription_error) { + factos.StoreError(UnexpectedResult(message:)) } -fn row_decode_error( - errors: List(decode.DecodeError), -) -> factos.Error(domain_error, subscription_error, StoreError) { - factos.StoreError(RowDecodeError(errors:)) +fn d1_error_to_string(error: d1.Error) -> String { + case error { + d1.BatchError(message) -> "batch failed: " <> message + d1.RunError(message) -> "statement failed: " <> message + d1.DecodeErrors(errors) -> + "row decoding failed: " + <> string.join(list.map(errors, decode_error_to_string), with: "; ") + d1.StatementResultMismatch -> "batch statement/result count mismatch" + } } fn decode_error_to_string(error: decode.DecodeError) -> String { let decode.DecodeError(expected:, found:, path:) = error - "expected: " + "expected " <> expected - <> ", found: " + <> ", found " <> found - <> ", path: [" - <> string.join(path, ",") + <> ", path [" + <> string.join(path, ", ") <> "]" } diff --git a/backends/factos_cf/test/factos_cf_test.gleam b/backends/factos_cf/test/factos_cf_test.gleam index 5f5a694..e03aa8f 100644 --- a/backends/factos_cf/test/factos_cf_test.gleam +++ b/backends/factos_cf/test/factos_cf_test.gleam @@ -3,7 +3,9 @@ import cf/miniflare import cf/miniflare/bindings import factos import factos/factos_cf +import gleam/dynamic/decode import gleam/javascript/promise.{type Promise} +import gleam/json import gleam/list import gleam/option import gleam/result @@ -33,7 +35,7 @@ type TestDatabase { TestDatabase(miniflare: miniflare.Miniflare, database: d1.Database) } -pub fn shared_dispatch_persists_and_reads_context_test() -> Promise(Nil) { +pub fn dispatch_persists_and_reads_context_test() -> Promise(Nil) { use database <- with_test_database use dispatch_result <- promise.await(dispatch_reservation( database, @@ -41,7 +43,6 @@ pub fn shared_dispatch_persists_and_reads_context_test() -> Promise(Nil) { "renata", )) let assert Ok(dispatch) = dispatch_result - let assert factos.SequencePosition(_) = dispatch.position let assert [recorded] = dispatch.events assert_reserved_recorded( recorded, @@ -63,13 +64,10 @@ pub fn shared_dispatch_persists_and_reads_context_test() -> Promise(Nil) { decision_context: factos.AllEvents, after: dispatch.position, ) - promise.resolve(Nil) } -pub fn shared_dispatch_rejects_duplicate_from_matching_context_test() -> Promise( - Nil, -) { +pub fn dispatch_rejects_duplicate_from_matching_context_test() -> Promise(Nil) { use database <- with_test_database let decision_context = reservation_context("renata") @@ -87,13 +85,11 @@ pub fn shared_dispatch_rejects_duplicate_from_matching_context_test() -> Promise )) let assert Error(factos.DomainError(AlreadyReserved(name: "renata"))) = duplicate_result - promise.resolve(Nil) } pub fn decision_context_conformance_test() -> Promise(Nil) { use database <- with_test_database - use renata_result <- promise.await(dispatch_reservation( database, factos.NoContext, @@ -127,21 +123,16 @@ pub fn decision_context_conformance_test() -> Promise(Nil) { database, compound_context, )) - let assert Ok(context) = compound_result - let assert [lucy] = context.events + let assert Ok(compound) = compound_result + let assert [lucy] = compound.events assert_reserved_recorded( lucy, position: lucy_dispatch.position, id: "event-lucy", name: "lucy", ) - assert context.state == ["lucy"] - assert context.position == lucy_dispatch.position - assert context.append_condition - == factos.FailIfEventsMatch( - decision_context: compound_context, - after: lucy_dispatch.position, - ) + assert compound.state == ["lucy"] + assert compound.position == lucy_dispatch.position use all_result <- promise.await(read_reservations(database, factos.AllEvents)) let assert Ok(all_context) = all_result @@ -166,7 +157,69 @@ pub fn decision_context_conformance_test() -> Promise(Nil) { ) assert all_context.state == ["marc", "lucy", "renata"] assert all_context.position == marc_dispatch.position + promise.resolve(Nil) +} + +pub fn read_after_orders_filters_and_bounds_pages_test() -> Promise(Nil) { + use database <- with_test_database + use renata_result <- promise.await(dispatch_reservation( + database, + factos.NoContext, + "renata", + )) + let assert Ok(renata_dispatch) = renata_result + use maria_result <- promise.await(dispatch_reservation( + database, + factos.NoContext, + "maria", + )) + let assert Ok(maria_dispatch) = maria_result + use lucy_result <- promise.await(dispatch_reservation( + database, + factos.NoContext, + "lucy", + )) + let assert Ok(lucy_dispatch) = lucy_result + use first_page_result <- promise.await(factos_cf.read_after( + database, + factos.AllEvents, + factos.NoPosition, + 2, + decode, + )) + let assert Ok([renata, maria]) = first_page_result + assert renata.position == renata_dispatch.position + assert maria.position == maria_dispatch.position + + use second_page_result <- promise.await(factos_cf.read_after( + database, + factos.AllEvents, + maria.position, + 2, + decode, + )) + let assert Ok([lucy]) = second_page_result + assert lucy.position == lucy_dispatch.position + + use filtered_result <- promise.await(factos_cf.read_after( + database, + reservation_context("maria"), + factos.NoPosition, + 10, + decode, + )) + let assert Ok([filtered]) = filtered_result + assert filtered.position == maria_dispatch.position + + use empty_result <- promise.await(factos_cf.read_after( + database, + factos.AllEvents, + factos.NoPosition, + 0, + decode, + )) + let assert Ok([]) = empty_result promise.resolve(Nil) } @@ -179,17 +232,20 @@ pub fn empty_dispatch_has_no_position_or_events_test() -> Promise(Nil) { )) let assert Ok(seeded_dispatch) = seeded_result - let builder = - factos.new_dispatch( - connection: database, - decision_context: factos.AllEvents, - decider: empty_reservation_decider(), - codec: codec(), + let configuration = + factos.model( + decider: fn(_) { empty_reservation_decider() }, + encode:, + decode:, ) + |> factos_cf.configure(connection: database) use empty_result <- promise.await( - factos_cf.dispatch(builder, Reserve(name: "ignored"), event_id: fn() { - "unused" - }), + factos_cf.dispatch( + configuration, + Reserve(name: "ignored"), + decision_context: factos.AllEvents, + event_id: fn() { "unused" }, + ), ) let assert Ok(empty_dispatch) = empty_result assert empty_dispatch @@ -202,52 +258,91 @@ pub fn empty_dispatch_has_no_position_or_events_test() -> Promise(Nil) { let assert Ok(context) = context_result assert context.events == seeded_dispatch.events assert context.state == ["seeded"] - promise.resolve(Nil) } -pub fn subscriptions_are_rejected_before_database_io_test() -> Promise(Nil) { +pub fn strong_subscription_commits_with_dispatch_test() -> Promise(Nil) { use database <- with_test_database let subscription = - factos.new_subscription( - decision_context: factos.AllEvents, - consistency: factos.StrongConsistency, - handle: fn(_database, _recorded) { Ok(Nil) }, + factos.subscription(apply: fn(plan, pending) { + let factos_cf.PendingRecorded(id:, event:) = pending + let Reserved(name:) = event.payload + factos_cf.add_mutation( + plan, + sql: "insert into factos_cf_test_projection ( + name, source_event_id, source_position + ) + select ?, id, position from factos_events where id = ?", + parameters: [name, id], + ) + |> Ok + }) + let configuration = + factos_cf.Configuration( + ..reservation_configuration(database), + subscriptions: [subscription], ) - let builder = - factos.new_dispatch( - connection: database, + + use dispatch_result <- promise.await( + factos_cf.dispatch( + configuration, + Reserve(name: "renata"), decision_context: factos.NoContext, - decider: reservation_decider(), - codec: codec(), + event_id: fn() { "event-renata" }, + ), + ) + let assert Ok(dispatch) = dispatch_result + use projection_result <- promise.await(projected_reservations(database)) + let assert Ok([#("renata", "event-renata", position)]) = projection_result + assert factos.SequencePosition(position) == dispatch.position + promise.resolve(Nil) +} + +pub fn strong_subscription_failure_rolls_back_dispatch_test() -> Promise(Nil) { + use database <- with_test_database + let subscription = + factos.subscription(apply: fn(plan, _pending) { + factos_cf.add_mutation( + plan, + sql: "insert into missing_strong_projection(value) values (?)", + parameters: ["fail"], + ) + |> Ok + }) + let configuration = + factos_cf.Configuration( + ..reservation_configuration(database), + subscriptions: [subscription], ) - |> factos.with_subscriptions(subscriptions: [subscription]) use dispatch_result <- promise.await( - factos_cf.dispatch(builder, Reserve(name: "not-written"), event_id: fn() { - "event-not-written" - }), + factos_cf.dispatch( + configuration, + Reserve(name: "not-written"), + decision_context: factos.NoContext, + event_id: fn() { "event-not-written" }, + ), ) - let assert Error(factos.StoreError(factos_cf.SubscriptionsNotSupported)) = + let assert Error(factos.StoreError(factos_cf.D1Error(d1.BatchError(_)))) = dispatch_result - use context_result <- promise.await(read_reservations( database, factos.AllEvents, )) let assert Ok(context) = context_result assert context.events == [] - + use projection_result <- promise.await(projected_reservations(database)) + let assert Ok([]) = projection_result promise.resolve(Nil) } -pub fn unknown_stored_event_uses_shared_decode_error_test() -> Promise(Nil) { +pub fn unknown_stored_schema_is_reported_test() -> Promise(Nil) { use database <- with_test_database use insert_result <- promise.await(insert_raw_event( database, id: "unknown-event", type_: "unknown", - data: "payload", + data: json.to_string(json.string("payload")), )) let assert Ok(Nil) = insert_result @@ -255,24 +350,53 @@ pub fn unknown_stored_event_uses_shared_decode_error_test() -> Promise(Nil) { database, factos.AllEvents, )) - let assert Error(factos.DecodeError(factos.UnknownEvent)) = context_result + let assert Error(factos.InvalidSchema(factos.EventType("unknown"), 1)) = + context_result + promise.resolve(Nil) +} +pub fn malformed_stored_payload_is_reported_test() -> Promise(Nil) { + use database <- with_test_database + use insert_result <- promise.await(insert_raw_event( + database, + id: "malformed-event", + type_: "reserved", + data: "not-json", + )) + let assert Ok(Nil) = insert_result + + use context_result <- promise.await(read_reservations( + database, + factos.AllEvents, + )) + let assert Error(factos.DecodeError(_)) = context_result promise.resolve(Nil) } fn with_test_database(run: fn(d1.Database) -> Promise(Nil)) -> Promise(Nil) { use test_database <- promise.await(new_test_database()) - use migration_result <- promise.await(migrate_test_database( + use migration_result <- promise.await(factos_cf.migrate( test_database.database, )) - case migration_result { - Ok(Nil) -> Nil - Error(error) -> panic as error_to_string(error) - } + let assert Ok(Nil) = migration_result + use projection_result <- promise.await(execute_sql( + test_database.database, + "create table if not exists factos_cf_test_projection ( + name text not null, + source_event_id text not null unique, + source_position integer not null + )", + )) + let assert Ok(Nil) = projection_result + use clear_projection_result <- promise.await(execute_sql( + test_database.database, + "delete from factos_cf_test_projection", + )) + let assert Ok(Nil) = clear_projection_result use clear_result <- promise.await(clear_events(test_database.database)) let assert Ok(Nil) = clear_result use Nil <- promise.await(run(test_database.database)) - use Nil <- promise.await(dispose(test_database)) + use Nil <- promise.await(miniflare.dispose(test_database.miniflare)) promise.resolve(Nil) } @@ -295,20 +419,36 @@ fn new_test_database() -> Promise(TestDatabase) { promise.resolve(TestDatabase(miniflare:, database:)) } -fn dispose(test_database: TestDatabase) -> Promise(Nil) { - miniflare.dispose(test_database.miniflare) +fn clear_events(database: d1.Database) -> Promise(Result(Nil, d1.Error)) { + d1.prepare(database, "delete from factos_events") + |> d1.run + |> promise.map(fn(run_result) { run_result |> result.map(fn(_) { Nil }) }) } -fn migrate_test_database( +fn execute_sql( database: d1.Database, -) -> Promise(Result(Nil, factos.Error(DomainError, Nil, factos_cf.StoreError))) { - factos_cf.migrate(database) + sql: String, +) -> Promise(Result(Nil, d1.Error)) { + d1.prepare(database, sql) + |> d1.run + |> promise.map(fn(run_result) { run_result |> result.map(fn(_) { Nil }) }) } -fn clear_events(database: d1.Database) -> Promise(Result(Nil, String)) { - d1.prepare(database, "delete from factos_events") +fn projected_reservations( + database: d1.Database, +) -> Promise(Result(List(#(String, String, Int)), d1.Error)) { + d1.prepare( + database, + "select name, source_event_id, source_position + from factos_cf_test_projection order by source_position", + ) + |> d1.returning({ + use name <- decode.field("name", decode.string) + use source_event_id <- decode.field("source_event_id", decode.string) + use source_position <- decode.field("source_position", decode.int) + decode.success(#(name, source_event_id, source_position)) + }) |> d1.run - |> promise.map(fn(run_result) { run_result |> result.map(fn(_) { Nil }) }) } fn insert_raw_event( @@ -316,73 +456,59 @@ fn insert_raw_event( id id: String, type_ type_: String, data data: String, -) -> Promise(Result(Nil, String)) { +) -> Promise(Result(Nil, d1.Error)) { d1.prepare( database, "insert into factos_events (id, type, version, tags, metadata, data) - values (?, ?, 1, '', '', ?)", + values (?, ?, 1, '[]', '{}', ?)", ) |> d1.bind([id, type_, data]) |> d1.run |> promise.map(fn(run_result) { run_result |> result.map(fn(_) { Nil }) }) } +fn reservation_configuration(database: d1.Database) { + factos.model(decider: fn(_) { reservation_decider() }, encode:, decode:) + |> factos_cf.configure(connection: database) +} + fn dispatch_reservation( database: d1.Database, decision_context: factos.DecisionContext, name: String, -) -> Promise( - Result( - factos.Dispatch(Event), - factos.Error(DomainError, Nil, factos_cf.StoreError), - ), -) { - factos.new_dispatch( - connection: database, - decision_context:, - decider: reservation_decider(), - codec: codec(), - ) - |> factos_cf.dispatch(Reserve(name:), event_id: fn() { "event-" <> name }) +) -> Promise(Result(factos.Dispatch(Event), factos_cf.Error(DomainError, Nil))) { + reservation_configuration(database) + |> factos_cf.dispatch(Reserve(name:), decision_context:, event_id: fn() { + "event-" <> name + }) } fn read_reservations( database: d1.Database, decision_context: factos.DecisionContext, ) -> Promise( - Result( - factos.Context(Event, List(String)), - factos.Error(DomainError, Nil, factos_cf.StoreError), - ), + Result(factos.Context(Event, List(String)), factos_cf.Error(DomainError, Nil)), ) { - factos_cf.read( - database, - decision_context:, - decider: reservation_decider(), - codec: codec(), - ) + factos_cf.read(database, decision_context, reservation_decider(), decode) } fn reservation_context(name: String) -> factos.DecisionContext { factos.Matching(items: [ - factos.item(types: [factos.event_type("reserved")], tags: [ - factos.tag("name:" <> name), + factos.Item(types: [factos.EventType("reserved")], tags: [ + factos.Tag("name:" <> name), ]), ]) } fn reservation_conformance_context() -> factos.DecisionContext { factos.Matching(items: [ - factos.item(types: [factos.event_type("reserved")], tags: [ - factos.tag("name:renata"), - factos.tag("name:lucy"), + factos.Item(types: [factos.EventType("reserved")], tags: [ + factos.Tag("name:renata"), + factos.Tag("name:lucy"), ]), - factos.item( - types: [ - factos.event_type("unknown"), - factos.event_type("reserved"), - ], - tags: [factos.tag("name:lucy")], + factos.Item( + types: [factos.EventType("unknown"), factos.EventType("reserved")], + tags: [factos.Tag("name:lucy")], ), ]) } @@ -421,25 +547,28 @@ fn evolve(state: List(String), event: Event) -> List(String) { [name, ..state] } -fn codec() -> factos.EventCodec(Event, String) { - factos.codec(encode:, decode:) -} - -fn encode(event: Event) -> factos.Event(String) { +fn encode(event: Event) -> factos.Event(json.Json) { let Reserved(name:) = event - factos.new_event(type_: factos.event_type("reserved"), version: 1, data: name) - |> factos.with_tags(tags: [factos.tag("name:" <> name)]) + factos.event( + type_: factos.EventType("reserved"), + version: 1, + data: json.string(name), + ) + |> factos.with_tags(tags: [factos.Tag("name:" <> name)]) + |> factos.with_metadata( + metadata: factos.metadata([ + #("correlation_id", "event-" <> name), + ]), + ) } fn decode( - stored: factos.Recorded(String), -) -> Result(Event, factos.DecodeError) { - case - factos.event_type_name(stored.descriptor.type_), - stored.descriptor.version - { - "reserved", 1 -> Ok(Reserved(name: stored.event)) - _, _ -> Error(factos.UnknownEvent) + type_: factos.EventType, + version: Int, +) -> Result(decode.Decoder(Event), Nil) { + case type_, version { + factos.EventType("reserved"), 1 -> Ok(decode.string |> decode.map(Reserved)) + _, _ -> Error(Nil) } } @@ -451,23 +580,10 @@ fn assert_reserved_recorded( ) -> Nil { assert recorded.id == id assert recorded.position == position - assert recorded.descriptor.type_ == factos.event_type("reserved") - assert recorded.descriptor.version == 1 - assert recorded.descriptor.tags == [factos.tag("name:" <> name)] - assert recorded.descriptor.metadata == factos.empty_metadata() - assert recorded.event == Reserved(name:) -} - -fn error_to_string( - error: factos.Error(DomainError, Nil, factos_cf.StoreError), -) -> String { - factos.error_to_string( - error, - fn(error) { - let AlreadyReserved(name:) = error - "already reserved: " <> name - }, - fn(_) { "subscription error" }, - factos_cf.store_error_to_string, - ) + assert recorded.event.descriptor.type_ == factos.EventType("reserved") + assert recorded.event.descriptor.version == 1 + assert recorded.event.descriptor.tags == [factos.Tag("name:" <> name)] + assert recorded.event.descriptor.metadata + == factos.metadata([#("correlation_id", "event-" <> name)]) + assert recorded.event.payload == Reserved(name:) } diff --git a/backends/factos_pog/CHANGELOG.md b/backends/factos_pog/CHANGELOG.md new file mode 100644 index 0000000..58f9991 --- /dev/null +++ b/backends/factos_pog/CHANGELOG.md @@ -0,0 +1 @@ +# factos_pog changelog diff --git a/backends/factos_pog/README.md b/backends/factos_pog/README.md index 60c7c72..215ae6b 100644 --- a/backends/factos_pog/README.md +++ b/backends/factos_pog/README.md @@ -71,85 +71,59 @@ enforces UUIDv4 identity, removes the legacy stream and revision model, and adds commit-ordered global positions. The schema migration also installs the per-table automatic `ANALYZE` settings. -## Define event encoding and decoding +## Define the model and dispatch commands -The domain event type remains application-owned. PostgreSQL stores JSONB event -data and a store-visible descriptor. `factos.new_dispatch` receives the -application encoder and decoder as separate functions. - -The encoder prepares an event for persistence: +The domain event type and codec remain application-owned. PostgreSQL stores JSONB +payloads and their store-visible descriptors: ```gleam fn encode(event: Event) -> factos.Event(json.Json) { - case event { - TicketSold(ticket_id:, buyer:) -> - factos.new_event( - type_: factos.event_type("TicketSold"), - version: 1, - data: json.object([ - #("ticket_id", json.string(ticket_id)), - #("buyer", json.string(buyer)), - ]), - ) - |> factos.with_tags(tags: [ - factos.tag("ticket:" <> ticket_id), - ]) - } + let TicketSold(ticket_id:, buyer:) = event + factos.event( + type_: factos.EventType("TicketSold"), + version: 1, + data: json.object([ + #("ticket_id", json.string(ticket_id)), + #("buyer", json.string(buyer)), + ]), + ) + |> factos.with_tags(tags: [factos.Tag("ticket:" <> ticket_id)]) } -``` -`new_event` starts with no tags and empty metadata. Replace either optional -value through `with_tags` or `with_metadata`. - -The decoder receives the stored id, position, and a string event envelope: - -```gleam -fn decode( - stored: factos.Recorded(String), -) -> Result(Event, factos.Recorded(String)) { - case - factos.event_type_to_string(stored.event.descriptor.type_), - stored.event.descriptor.version - { - "TicketSold", 1 -> - json.parse(stored.event.payload, using: ticket_sold_decoder()) - |> result.replace_error(stored) - _, _ -> Error(stored) +fn decode(type_: factos.EventType, version: Int) { + case type_, version { + factos.EventType("TicketSold"), 1 -> Ok(ticket_sold_decoder()) + _, _ -> Error(Nil) } } ``` -The backend reconstructs a `factos.Recorded(event)` containing the event id, -global position, and an event envelope with the decoded payload and descriptor. +Build reusable configuration once. The decider factory can select command- +specific initial state: -Tags are part of the context-selection contract. If a future command must find -an event by a payload value, expose that value as a tag when encoding the event. -Metadata does not participate in matching. - -## Dispatch commands +```gleam +let configuration = + factos.default( + connection:, + decider: fn(_command) { ticket_decider() }, + encode:, + decode:, + ) +``` -Every dispatch requires an explicit decision context: +Each dispatch supplies the command's decision context: ```gleam -fn ticket_context(ticket_id: String) -> factos.DecisionContext { - factos.Matching(items: [ - factos.item( - types: [factos.event_type("TicketSold")], - tags: [factos.tag("ticket:" <> ticket_id)], - ), - ]) -} - let assert Ok(dispatch) = - factos.new_dispatch( - connection:, - decision_context: ticket_context(ticket_id), - decider: ticket_decider(), - encode: encode, - decode: decode, - ) + configuration |> factos_pog.dispatch( BuyTicket(ticket_id:, buyer: "renata"), + decision_context: factos.Matching(items: [ + factos.Item( + types: [factos.EventType("TicketSold")], + tags: [factos.Tag("ticket:" <> ticket_id)], + ), + ]), event_id: uuid.v4_string, ) ``` @@ -187,45 +161,37 @@ no subscription callbacks. ## Subscribe to a dispatch -A subscription receives every decoded record accepted by the dispatch to which -it is attached: +A subscription receives every decoded record accepted by its carrying +dispatch: ```gleam let projection = - factos.new_subscription( - consistency: factos.StrongConsistency, - handle: fn(transaction_connection, recorded) { - user_projection.apply(transaction_connection, recorded) - }, - ) -``` - -Route selectively inside the callback by matching `recorded.event.payload`, or -attach subscriptions only to dispatchers whose complete event set they handle. + factos.subscription(handle: fn(transaction_connection, recorded) { + user_projection.apply(transaction_connection, recorded) + }) -Attach the complete subscription list with `with_subscriptions`. All entries in -one list share one callback error type: +let configuration = + factos.default( + connection:, + decider: fn(_command) { user_decider() }, + encode: encode_event, + decode: decode_event, + ) + |> factos.with_subscriptions([projection]) -```gleam -factos.new_dispatch( - connection:, - decision_context: registration_context(username), - decider: user_decider(), - encode: encode_event, - decode: decode_event, -) -|> factos.with_subscriptions(subscriptions: [projection]) +configuration |> factos_pog.dispatch( RegisterUser(username:), + decision_context: registration_context(username), event_id: uuid.v4_string, ) ``` -### Strong consistency +### Strong subscriptions -`StrongConsistency` callbacks run in subscription-list order and event append -order inside the dispatch transaction. Every PostgreSQL operation must use the -supplied transaction connection. +Strong callbacks run in subscription-list order and event append order inside +the dispatch transaction. Every PostgreSQL operation must use the supplied +transaction connection. The first callback `Error` becomes `SubscriptionError(error: callback_error)` and rolls back all accepted events, @@ -237,24 +203,24 @@ transaction, including strong callbacks already run by the aborted attempt. Keep all strong callback side effects on the supplied connection so rollback and retry remain safe. -### Fire and forget +### After commit -Use the shared fire-and-forget consistency mode: +Use an after-commit subscription for best-effort observation: ```gleam let observer = - factos.new_subscription( - consistency: factos.FireAndForget, + factos.new_after_commit_subscription( handle: fn(connection, recorded) { registration_observer.handle(connection, recorded) }, ) ``` -After the final successful commit, `factos_pog` starts one process per -fire-and-forget subscription. The process receives the builder's ordinary Pog -connection and handles every accepted record in append order. Different -subscriptions and dispatches may run concurrently. +Attach it with `factos.with_after_commit_subscriptions`. After the final +successful commit, `factos_pog` starts one process per after-commit +subscription. The process receives the builder's ordinary Pog connection and +handles every accepted record in append order. Different subscriptions and +dispatches may run concurrently. Dispatch does not wait for these callbacks. Returned `Ok` and `Error` values are ignored and processing continues. A panic terminates that process and drops diff --git a/examples/performance/.gitignore b/backends/factos_pog/benchmark/.gitignore similarity index 100% rename from examples/performance/.gitignore rename to backends/factos_pog/benchmark/.gitignore diff --git a/examples/performance/README.md b/backends/factos_pog/benchmark/README.md similarity index 92% rename from examples/performance/README.md rename to backends/factos_pog/benchmark/README.md index 684d5cf..075fa41 100644 --- a/examples/performance/README.md +++ b/backends/factos_pog/benchmark/README.md @@ -1,8 +1,8 @@ # Factos Pog performance A standalone PostgreSQL stress benchmark for the Factos Pog dispatch path. It -starts an isolated PostgreSQL container, applies the current Factos Pog schema, -and exercises real `factos_pog.dispatch` calls. +connects to PostgreSQL using the `FACTOS_POG_*` environment variables, applies +the current Factos Pog schema, and exercises real `factos_pog.dispatch` calls. ## Scenarios @@ -97,8 +97,10 @@ gleam run FACTOS_PERF_PROFILE=stress gleam run ``` -The run creates and removes its own PostgreSQL container. No developer-managed -database is required. +The run defaults to `postgres@127.0.0.1:5432/performance`, provided by the root +Compose service. Override `FACTOS_POG_HOST`, `FACTOS_POG_PORT`, +`FACTOS_POG_DATABASE`, `FACTOS_POG_USERNAME`, and `FACTOS_POG_PASSWORD` as +needed. ## Package layout diff --git a/examples/performance/gleam.toml b/backends/factos_pog/benchmark/gleam.toml similarity index 50% rename from examples/performance/gleam.toml rename to backends/factos_pog/benchmark/gleam.toml index 46f816c..1837e27 100644 --- a/examples/performance/gleam.toml +++ b/backends/factos_pog/benchmark/gleam.toml @@ -1,17 +1,15 @@ -name = "factos_pog_performance" +name = "benchmark" version = "1.0.0" [dependencies] envoy = ">= 1.0.0 and < 2.0.0" -factos = { path = "../.." } -factos_pog = { path = "../../backends/factos_pog" } +factos = { path = "../../.." } +factos_pog = { path = ".." } 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" gleam_json = ">= 3.1.0 and < 4.0.0" -pog = { git = "https://github.com/foxfriends/pog.git", ref = "919fd6ac96095ea11fa7c940b17eaece49cc5993" } +pog = ">= 4.1.0 and < 5.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" gleamy_bench = ">= 0.6.0 and < 1.0.0" diff --git a/examples/performance/manifest.toml b/backends/factos_pog/benchmark/manifest.toml similarity index 73% rename from examples/performance/manifest.toml rename to backends/factos_pog/benchmark/manifest.toml index e0c3872..27cd7f0 100644 --- a/examples/performance/manifest.toml +++ b/backends/factos_pog/benchmark/manifest.toml @@ -8,12 +8,10 @@ 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 = "envoy", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "9C6FBB6BFA02A52798BEEC5977A738CAD6E4A057F4B67FD0C8061AD2502C191A" }, { name = "exception", version = "2.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "6BDEA95248093599391C3B5DF1835C5C6A86C353C2F99CE539B450E3432FE117" }, - { name = "factos", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], source = "local", path = "../.." }, - { name = "factos_pog", version = "2.0.0", build_tools = ["gleam"], requirements = ["exception", "factos", "gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib", "pog"], source = "local", path = "../../backends/factos_pog" }, + { name = "factos", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], source = "local", path = "../../.." }, + { name = "factos_pog", version = "2.0.0", build_tools = ["gleam"], requirements = ["factos", "gleam_json", "gleam_otp", "gleam_stdlib", "pog"], source = "local", path = ".." }, { 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" }, @@ -25,24 +23,20 @@ packages = [ { 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 = "pog", version = "4.1.0", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_otp", "gleam_stdlib", "gleam_time", "pgo"], otp_app = "pog", source = "hex", outer_checksum = "E4AFBA39A5FAA2E77291836C9683ADE882E65A06AB28CA7D61AE7A3AD61EBBD5" }, { 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] envoy = { version = ">= 1.0.0 and < 2.0.0" } -factos = { path = "../.." } -factos_pog = { path = "../../backends/factos_pog" } +factos = { path = "../../.." } +factos_pog = { path = ".." } 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" } gleamy_bench = { version = ">= 0.6.0 and < 1.0.0" } -pog = { git = "https://github.com/foxfriends/pog.git", ref = "919fd6ac96095ea11fa7c940b17eaece49cc5993" } +pog = { version = ">= 4.1.0 and < 5.0.0" } 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/backends/factos_pog/benchmark/src/benchmark.gleam similarity index 92% rename from examples/performance/src/factos_pog_performance.gleam rename to backends/factos_pog/benchmark/src/benchmark.gleam index 8eff21c..0c4a12d 100644 --- a/examples/performance/src/factos_pog_performance.gleam +++ b/backends/factos_pog/benchmark/src/benchmark.gleam @@ -16,9 +16,6 @@ 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 benchmark_batch_stride = 1_000_000 @@ -104,7 +101,7 @@ pub fn main() -> Nil { Nil } -fn run() -> Result(Nil, testcontainer_error.Error) { +fn run() -> Result(Nil, Nil) { let profile = envoy.get("FACTOS_PERF_PROFILE") |> result.map(profile_from_string) @@ -117,10 +114,7 @@ fn run() -> Result(Nil, testcontainer_error.Error) { "Each scenario resets the event store before setup and reports fixed-workload latency and throughput.", ) - use postgres_container <- testcontainer.with_formula( - postgres.new() |> postgres.formula(), - ) - let #(pool_pid, connection) = start_connection(postgres_container) + let #(pool_pid, connection) = start_connection() execute_migration_file(connection) case profile { @@ -351,33 +345,24 @@ fn encode( ) } - factos.new_event( - type_: factos.event_type(type_name), - version: 1, - data: payload, - ) + factos.event(type_: factos.EventType(type_name), version: 1, data: payload) |> factos.with_tags(tags: list.append(event_tags, extra_tags)) } fn decode( - stored: factos.Recorded(String), -) -> Result(Event, factos.Recorded(String)) { - use decoder <- result.try( - case - factos.event_type_to_string(stored.event.descriptor.type_), - stored.event.descriptor.version - { - "performance.user_registered", 1 -> Ok(user_registered_decoder()) - "performance.email_changed", 1 -> Ok(email_changed_decoder()) - "performance.balance_adjusted", 1 -> Ok(balance_adjusted_decoder()) - "performance.user_suspended", 1 -> Ok(user_suspended_decoder()) - "performance.subject_advanced", 1 -> Ok(subject_advanced_decoder()) - "performance.payload_recorded", 1 -> Ok(payload_recorded_decoder()) - _, _ -> Error(stored) - }, - ) - json.parse(stored.event.payload, using: decoder) - |> result.replace_error(stored) + type_: factos.EventType, + version: Int, +) -> Result(decode.Decoder(Event), Nil) { + let factos.EventType(name) = type_ + case name, version { + "performance.user_registered", 1 -> Ok(user_registered_decoder()) + "performance.email_changed", 1 -> Ok(email_changed_decoder()) + "performance.balance_adjusted", 1 -> Ok(balance_adjusted_decoder()) + "performance.user_suspended", 1 -> Ok(user_suspended_decoder()) + "performance.subject_advanced", 1 -> Ok(subject_advanced_decoder()) + "performance.payload_recorded", 1 -> Ok(payload_recorded_decoder()) + _, _ -> Error(Nil) + } } fn user_registered_decoder() -> decode.Decoder(Event) { @@ -413,13 +398,13 @@ fn payload_recorded_decoder() -> decode.Decoder(Event) { } fn subject_tag(subject_id: String) -> factos.Tag { - factos.tag("subject:" <> subject_id) + factos.Tag("subject:" <> subject_id) } fn subject_context(subject_id: String) -> factos.DecisionContext { factos.Matching([ - factos.item( - types: [factos.event_type("performance.subject_advanced")], + factos.Item( + types: [factos.EventType("performance.subject_advanced")], tags: [ subject_tag(subject_id), ], @@ -432,19 +417,20 @@ fn dispatch_command( command: Command, decision_context: factos.DecisionContext, encode: Encoder, - subscriptions: List(factos.Subscription(Event, String, pog.Connection)), + subscriptions: List( + factos.Subscription(factos.Recorded(Event), String, pog.Connection), + ), retry_attempts: Int, ) -> Result(factos.Dispatch(Event), BenchmarkError) { - factos.new_dispatch( - connection:, - decider: decider(), - decision_context:, - encode:, - decode:, + factos_pog.Configuration( + ..factos_pog.configure( + factos.model(decider: fn(_) { decider() }, encode:, decode:), + connection:, + ), + retry_attempts:, + subscriptions:, ) - |> factos.with_retry_attempts(retry_attempts) - |> factos.with_subscriptions(subscriptions:) - |> factos_pog.dispatch(command, event_id: uuid.v4_string) + |> factos_pog.dispatch(command, decision_context:, event_id: uuid.v4_string) } fn run_append_batch_scenarios( @@ -509,7 +495,7 @@ fn build_benchmark_tags( current: current + 1, remaining: remaining - 1, accumulated: [ - factos.tag("benchmark:" <> int.to_string(current)), + factos.Tag("benchmark:" <> int.to_string(current)), ..accumulated ], ) @@ -961,26 +947,28 @@ fn subscription_scenario_name(scenario: SubscriptionScenario) -> String { } } -fn no_op_subscription() -> factos.Subscription(Event, String, pog.Connection) { - factos.new_subscription( - consistency: factos.StrongConsistency, - handle: fn(_connection, _recorded) { Ok(Nil) }, - ) +fn no_op_subscription() -> factos.Subscription( + factos.Recorded(Event), + String, + pog.Connection, +) { + factos.subscription(apply: fn(connection, _recorded) { Ok(connection) }) } fn projection_subscription() -> factos.Subscription( - Event, + factos.Recorded(Event), String, pog.Connection, ) { - factos.new_subscription( - consistency: factos.StrongConsistency, - handle: fn(connection, recorded) { - pog.query("insert into factos_perf_projection (event_id) values ($1)") - |> pog.parameter(pog.text(recorded.id)) - |> pog.execute(on: connection) - |> result.map(fn(_) { Nil }) - |> result.map_error(string.inspect) + factos.subscription( + apply: fn(connection: pog.Connection, recorded: factos.Recorded(Event)) { + use _ <- result.try( + pog.query("insert into factos_perf_projection (event_id) values ($1)") + |> pog.parameter(pog.text(recorded.id)) + |> pog.execute(on: connection) + |> result.map_error(string.inspect), + ) + Ok(connection) }, ) } @@ -1154,11 +1142,17 @@ fn string_column_decoder() -> decode.Decoder(String) { decode.success(value) } -fn start_connection( - postgres_container: postgres.PostgresContainer, -) -> #(process.Pid, pog.Connection) { - let postgres.PostgresContainer(host:, port:, database:, username:, ..) = - postgres_container +fn start_connection() -> #(process.Pid, pog.Connection) { + let host = environment_variable("FACTOS_POG_HOST", default: "127.0.0.1") + let assert Ok(port) = + environment_variable("FACTOS_POG_PORT", default: "5432") + |> int.parse + let database = + environment_variable("FACTOS_POG_DATABASE", default: "performance") + let username = + environment_variable("FACTOS_POG_USERNAME", default: "postgres") + let password = + environment_variable("FACTOS_POG_PASSWORD", default: "postgres") let pool_name = process.new_name("factos_pog_performance") let config = pog.default_config(pool_name) @@ -1166,7 +1160,7 @@ fn start_connection( |> pog.port(port) |> pog.database(database) |> pog.user(username) - |> pog.password(Some("postgres")) + |> pog.password(Some(password)) |> pog.ssl(pog.SslDisabled) |> pog.pool_size(64) @@ -1175,6 +1169,11 @@ fn start_connection( #(pid, pog.named_connection(pool_name)) } +fn environment_variable(name: String, default default_value: String) -> String { + envoy.get(name) + |> result.unwrap(default_value) +} + fn reset_schema(connection: pog.Connection) -> Nil { let assert Ok(_) = pog.query("drop table if exists factos_perf_projection") diff --git a/backends/factos_pog/benchmark/test/benchmark_test.gleam b/backends/factos_pog/benchmark/test/benchmark_test.gleam new file mode 100644 index 0000000..c129017 --- /dev/null +++ b/backends/factos_pog/benchmark/test/benchmark_test.gleam @@ -0,0 +1,54 @@ +import benchmark +import factos + +pub fn main() -> Nil { + benchmark_events_are_deterministic() + subject_events_are_ordered() + profiles_are_selected_explicitly() + benchmark_tags_are_stable() + percentiles_use_nearest_rank() +} + +fn benchmark_events_are_deterministic() -> Nil { + assert benchmark.benchmark_events(batch: 2, count: 5) + == [ + benchmark.UserRegistered(user_id: 2_000_000), + benchmark.EmailChanged(email: "2000001@example.com"), + benchmark.BalanceAdjusted(delta: 2_000_002), + benchmark.UserSuspended(reason: "performance benchmark"), + benchmark.UserRegistered(user_id: 2_000_004), + ] +} + +fn subject_events_are_ordered() -> Nil { + assert benchmark.subject_events("wallet-1", 7, 3) + == [ + benchmark.SubjectAdvanced("wallet-1", 7), + benchmark.SubjectAdvanced("wallet-1", 8), + benchmark.SubjectAdvanced("wallet-1", 9), + ] +} + +fn profiles_are_selected_explicitly() -> Nil { + assert benchmark.profile_from_string("smoke") == benchmark.Smoke + assert benchmark.profile_from_string("STRESS") == benchmark.Stress + assert benchmark.profile_from_string("explain") == benchmark.Explain + assert benchmark.profile_from_string("unknown") == benchmark.Standard +} + +fn benchmark_tags_are_stable() -> Nil { + assert benchmark.benchmark_tags(3) + == [ + factos.Tag("benchmark:1"), + factos.Tag("benchmark:2"), + factos.Tag("benchmark:3"), + ] +} + +fn percentiles_use_nearest_rank() -> Nil { + let samples = [40.0, 10.0, 30.0, 20.0] + assert benchmark.percentile(samples, 0.0) == 10.0 + assert benchmark.percentile(samples, 50.0) == 20.0 + assert benchmark.percentile(samples, 95.0) == 40.0 + assert benchmark.percentile([], 99.0) == 0.0 +} diff --git a/backends/factos_pog/compose.yml b/backends/factos_pog/compose.yml deleted file mode 100644 index 031d187..0000000 --- a/backends/factos_pog/compose.yml +++ /dev/null @@ -1,14 +0,0 @@ -services: - postgres: - image: postgres:18 - environment: - POSTGRES_DB: factos_pog - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - ports: - - "55432:5432" - healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres -d factos_pog"] - interval: 1s - timeout: 5s - retries: 20 diff --git a/backends/factos_pog/dev/factos_pog_dev.gleam b/backends/factos_pog/dev/factos_pog_dev.gleam deleted file mode 100644 index f28b782..0000000 --- a/backends/factos_pog/dev/factos_pog_dev.gleam +++ /dev/null @@ -1,389 +0,0 @@ -import factos -import factos/factos_pog -import gleam/dynamic/decode -import gleam/erlang/application -import gleam/erlang/process -import gleam/int -import gleam/io -import gleam/json -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 operations_per_worker = 5 - -pub fn main() -> Nil { - let assert Ok(Nil) = run() - Nil -} - -fn run() -> Result(Nil, testcontainer_error.Error) { - io.println("factos_pog serializable dispatch benchmark") - io.println( - "Each benchmark iteration dispatches " - <> int.to_string(workers * operations_per_worker) - <> " commands.", - ) - - use postgres_container <- testcontainer.with_formula( - postgres.new() |> postgres.formula(), - ) - let #(pool_pid, connection) = start_connection(postgres_container) - let input = BenchmarkInput(connection:) - - bench.run( - [bench.Input("postgres", input)], - [ - bench.SetupFunction("sequential dispatch", setup_sequential), - bench.SetupFunction("concurrent dispatch", setup_concurrent), - ], - [], - ) - |> bench.table([bench.IPS, bench.Min, bench.Mean, bench.P(99)]) - |> io.println - - smoke_subscription(connection) - io.println("subscription smoke: strong committed; fire observed") - - process.send_exit(pool_pid) - process.sleep(100) - Ok(Nil) -} - -type BenchmarkInput { - BenchmarkInput(connection: pog.Connection) -} - -type Command { - Increment -} - -type Event { - Incremented(value: Int) -} - -type State { - Counter(total: Int) -} - -type WorkerMessage { - WorkerDone(worker: Int, result: Result(Nil, factos_pog.Error(Nil, Nil))) -} - -fn setup_sequential(input: BenchmarkInput) -> fn(BenchmarkInput) -> Nil { - reset_schema(input.connection) - fn(input: BenchmarkInput) { - run_sequential(input.connection, workers * operations_per_worker) - } -} - -fn setup_concurrent(input: BenchmarkInput) -> fn(BenchmarkInput) -> Nil { - reset_schema(input.connection) - fn(input: BenchmarkInput) { - run_concurrent(input.connection, workers, operations_per_worker) - } -} - -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_dev") - 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 reset_schema(connection: pog.Connection) -> Nil { - let assert Ok(_) = - pog.query("drop table if exists factos_dev_projection") - |> pog.execute(on: connection) - let assert Ok(_) = - pog.query("drop schema if exists factos cascade") - |> pog.execute(on: connection) - execute_migration_file(connection) -} - -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]) - } -} - -fn run_sequential(connection: pog.Connection, remaining: Int) -> Nil { - case remaining <= 0 { - True -> Nil - False -> { - let assert Ok(_) = dispatch_once(connection) - run_sequential(connection, remaining - 1) - } - } -} - -fn run_concurrent( - connection: pog.Connection, - worker_count: Int, - operations_count: Int, -) -> Nil { - let subject = process.new_subject() - spawn_workers(connection, subject, worker_count, operations_count) - wait_for_workers(subject, worker_count) -} - -fn spawn_workers( - connection: pog.Connection, - subject: process.Subject(WorkerMessage), - worker_count: Int, - operations_count: Int, -) -> Nil { - case worker_count <= 0 { - True -> Nil - False -> { - let worker = worker_count - process.spawn(fn() { - let result = run_worker(connection, operations_count) - process.send(subject, WorkerDone(worker: worker, result: result)) - }) - spawn_workers(connection, subject, worker_count - 1, operations_count) - } - } -} - -fn wait_for_workers( - subject: process.Subject(WorkerMessage), - remaining: Int, -) -> Nil { - case remaining <= 0 { - True -> Nil - False -> { - let assert Ok(message) = process.receive(subject, within: 120_000) - case message { - WorkerDone(worker: _, result: Ok(Nil)) -> - wait_for_workers(subject, remaining - 1) - WorkerDone(worker:, result: Error(_)) -> { - io.println("benchmark worker " <> int.to_string(worker) <> " failed ") - panic as "benchmark worker failed" - } - } - } - } -} - -fn run_worker( - connection: pog.Connection, - remaining: Int, -) -> Result(Nil, factos_pog.Error(Nil, Nil)) { - case remaining <= 0 { - True -> Ok(Nil) - False -> { - use _ <- result.try(dispatch_once(connection)) - run_worker(connection, remaining - 1) - } - } -} - -fn dispatch_once( - connection: pog.Connection, -) -> Result(factos.Dispatch(Event), factos_pog.Error(Nil, Nil)) { - factos.new_dispatch( - connection:, - decision_context: factos.NoContext, - decider: decider(), - encode:, - decode:, - ) - |> factos.with_retry_attempts(100) - |> factos_pog.dispatch(Increment, event_id: uuid.v4_string) -} - -fn smoke_subscription(connection: pog.Connection) -> Nil { - reset_schema(connection) - let assert Ok(_) = - pog.query( - "create table factos_dev_projection ( - singleton boolean primary key default true check (singleton), - value integer not null - )", - ) - |> pog.execute(on: connection) - - let observed_events = process.new_subject() - let strong_projection = - factos.new_subscription( - consistency: factos.StrongConsistency, - handle: fn(transaction_connection, recorded) { - let factos.Recorded( - event: factos.Event(payload: Incremented(value:), ..), - .., - ) = recorded - pog.query( - "insert into factos_dev_projection (singleton, value) - values (true, $1) - on conflict (singleton) do update set value = excluded.value", - ) - |> pog.parameter(pog.int(value)) - |> pog.execute(on: transaction_connection) - |> result.map(fn(_) { Nil }) - |> result.map_error(string.inspect) - }, - ) - let fire_observer = - factos.new_subscription( - consistency: factos.FireAndForget, - handle: fn(_connection, recorded) { - process.send(observed_events, recorded) - Ok(Nil) - }, - ) - - let assert Ok(dispatch) = - factos.new_dispatch( - connection:, - decision_context: factos.AllEvents, - decider: decider(), - encode:, - decode:, - ) - |> factos.with_subscriptions(subscriptions: [ - strong_projection, - fire_observer, - ]) - |> factos_pog.dispatch(Increment, event_id: uuid.v4_string) - - // Strong work is visible as soon as dispatch returns because it committed in - // the same transaction as the recorded event. - let assert Ok(returned) = - pog.query("select value from factos_dev_projection where singleton = true") - |> pog.returning(int_column_decoder()) - |> pog.execute(on: connection) - let assert [value] = returned.rows - assert value == 1 - - let assert [recorded] = dispatch.events - let assert Ok(observed) = process.receive(observed_events, within: 5000) - assert observed == recorded - - Nil -} - -fn int_column_decoder() -> decode.Decoder(Int) { - use value <- decode.field(0, decode.int) - decode.success(value) -} - -fn decider() -> factos.Decider(Command, State, Event, Nil) { - factos.decider(initial: Counter(0), decide:, evolve:) -} - -fn decide(state: State, command: Command) -> Result(List(Event), Nil) { - let Counter(total) = state - case command { - Increment -> Ok([Incremented(value: total + 1)]) - } -} - -fn evolve(state: State, event: Event) -> State { - let Counter(total) = state - case event { - Incremented(..) -> Counter(total + 1) - } -} - -fn encode(event: Event) -> factos.Event(json.Json) { - let Incremented(value:) = event - factos.new_event( - type_: factos.event_type("Incremented"), - version: 1, - data: json.int(value), - ) - |> factos.with_tags(tags: [factos.tag("benchmark")]) -} - -fn decode( - stored: factos.Recorded(String), -) -> Result(Event, factos.Recorded(String)) { - case - factos.event_type_to_string(stored.event.descriptor.type_), - stored.event.descriptor.version - { - "Incremented", 1 -> - json.parse( - stored.event.payload, - using: decode.int |> decode.map(Incremented), - ) - |> result.replace_error(stored) - _, _ -> Error(stored) - } -} diff --git a/backends/factos_pog/docs/durable-effects.md b/backends/factos_pog/docs/durable-effects.md index 95cbb50..c262f78 100644 --- a/backends/factos_pog/docs/durable-effects.md +++ b/backends/factos_pog/docs/durable-effects.md @@ -1,6 +1,6 @@ # Durable Effects -`factos.FireAndForget` is not durable delivery. It starts only after a +An after-commit subscription is not durable delivery. It starts only after a successful dispatch commit, but a callback panic or node failure can drop the work. It has no catch-up or retry. @@ -37,8 +37,7 @@ supplied transaction connection: ```gleam let durable_ledger_effects = - factos.new_subscription( - consistency: factos.StrongConsistency, + factos.new_strong_subscription( handle: fn(transaction_connection, recorded) { let effects = ledger_effects(recorded) ledger_outbox.insert_all( @@ -57,7 +56,7 @@ let assert Ok(dispatch) = encode: encode_event, decode: decode_event, ) - |> factos.with_subscriptions(subscriptions: [ + |> factos.with_strong_subscriptions(subscriptions: [ durable_ledger_effects, ]) |> factos_pog.dispatch(command, event_id: uuid.v4_string) @@ -115,8 +114,7 @@ directly from a strong subscription: ```gleam let user_projection = - factos.new_subscription( - consistency: factos.StrongConsistency, + factos.new_strong_subscription( handle: fn(transaction_connection, recorded) { user_projection.apply(transaction_connection, recorded) }, diff --git a/backends/factos_pog/docs/how-it-works.md b/backends/factos_pog/docs/how-it-works.md index 0893116..c9da6ba 100644 --- a/backends/factos_pog/docs/how-it-works.md +++ b/backends/factos_pog/docs/how-it-works.md @@ -100,9 +100,9 @@ that contains every fact capable of changing the command's answer. ## Dispatch-bound subscriptions -`factos.new_subscription` combines one consistency mode and a callback. Every -attached subscription receives every event accepted by its dispatch; selective -routing belongs inside the callback. +`factos.new_strong_subscription` configures interactive transaction work; +`factos.new_after_commit_subscription` configures best-effort work after commit. +Every attached subscription receives every event accepted by its dispatch. Strong subscriptions run in list order. Within one subscription, accepted records run in append order. The first callback `Error` becomes @@ -111,12 +111,12 @@ tag rows, and every strong callback write in that transaction. A callback error is not retryable, although an enclosing serialization or deadlock failure can rerun the callback in a fresh attempt. -After the final commit, each `factos.FireAndForget` subscription gets one -independent process. That process handles every accepted record in append order -with the builder's ordinary Pog connection. Separate subscriptions and -dispatches may run concurrently. +After the final commit, each after-commit subscription gets one independent +process. That process handles every accepted record in append order with the +builder's ordinary Pog connection. Separate subscriptions and dispatches may +run concurrently. -A fire-and-forget callback's return value cannot alter the committed dispatch. +An after-commit callback's return value cannot alter the committed dispatch. Returned errors are ignored and processing continues. A panic terminates the process and drops its remaining records. There is no historical catch-up or retry. @@ -124,7 +124,7 @@ retry. ## Effects and delivery guarantees No transaction can atomically commit PostgreSQL rows and an external HTTP -request, message publish, or email send. Fire-and-forget work is therefore +request, message publish, or email send. After-commit work is therefore best-effort. For durable delivery, a strong callback inserts an application-owned outbox row diff --git a/backends/factos_pog/docs/subscriptions.md b/backends/factos_pog/docs/subscriptions.md index 2a557cc..0fb4baa 100644 --- a/backends/factos_pog/docs/subscriptions.md +++ b/backends/factos_pog/docs/subscriptions.md @@ -1,38 +1,25 @@ # Subscriptions -`factos.Subscription` binds a callback to one command dispatch. Every attached -subscription receives every decoded record in `Dispatch.events`; subscriptions -do not consume historical records or observe another dispatch. +Factos separates transactional subscriptions from work that starts after commit. +Every attached subscription receives every decoded record accepted by its +carrying dispatch; subscriptions do not consume historical records or observe +another dispatch. -Choose the consistency mode from the callback's required commit boundary: +## Strong subscriptions -- `factos.StrongConsistency` runs inside the event transaction; -- `factos.FireAndForget` starts best-effort work after commit. - -## Configure and attach subscriptions - -Subscriptions have one consistency mode and one callback: +An interactive backend subscription receives the backend transaction and the +accepted `factos.Recorded(event)`: ```gleam let user_projection_subscription = - factos.new_subscription( - consistency: factos.StrongConsistency, + factos.new_strong_subscription( handle: fn(transaction_connection, recorded) { user_projection.apply(transaction_connection, recorded) }, ) ``` -`new_subscription` is infallible. Route selectively inside the callback by -matching `recorded.event.payload`, or attach subscriptions only to dispatchers -whose complete event set they handle. - -The dispatch decoder has already decoded each `factos.Recorded(event)`. A -callback receives the event id, global position, and an event envelope containing -the descriptor, metadata, tags, and domain payload. There is no stream or -per-stream revision in the recorded envelope. - -Attach the complete list to a dispatch builder: +Attach the complete strong subscription list to the builder: ```gleam let assert Ok(dispatch) = @@ -43,7 +30,7 @@ let assert Ok(dispatch) = encode: encode_event, decode: decode_event, ) - |> factos.with_subscriptions(subscriptions: [ + |> factos.with_strong_subscriptions(subscriptions: [ user_projection_subscription, ]) |> factos_pog.dispatch( @@ -52,78 +39,52 @@ let assert Ok(dispatch) = ) ``` -`with_subscriptions` replaces the complete list, allowing the builder to adopt -the callbacks' shared error type without an error-conversion wrapper. - -An empty dispatch invokes no callback. - -## Strong consistency - -Strong callbacks run after accepted event and tag rows have been inserted but -before commit. Use the supplied transaction connection for every PostgreSQL -operation: - -```gleam -let projection = - factos.new_subscription( - consistency: factos.StrongConsistency, - handle: fn(transaction_connection, recorded) { - user_projection.apply(transaction_connection, recorded) - }, - ) -``` - -The backend traverses subscriptions in list order and accepted records in event -append order. A callback can observe database writes made by an earlier strong -callback in the same dispatch transaction. +The PostgreSQL backend traverses subscriptions in list order and accepted +records in append order. Event rows and tag rows are already visible through +the supplied transaction connection. A callback can observe writes made by an +earlier strong callback in the same transaction. The first returned `Error` becomes -`factos.SubscriptionError(error: callback_error)`. PostgreSQL rolls back: +`factos.SubscriptionError(error: callback_error)`. PostgreSQL rolls back the +event append and every strong subscription write. Callback errors are not +retryable. PostgreSQL serialization failures and deadlocks retry the complete +transaction, so strong work must remain deterministic and confined to the +supplied transaction connection. -- every event and tag row accepted by the command; -- the failing callback's database writes; -- every earlier strong callback write in the transaction. +Planning backends use `factos.new_strong_plan_subscription` instead. Its +callback returns an updated immutable transaction plan. `factos_cf` uses this +form to execute the conditional event append and every planned D1 mutation in +one transactional batch. -Callback errors are not retryable. PostgreSQL serialization failures and -deadlocks are retryable, so a strong callback can run again after an aborted -attempt. External effects cannot be rolled back. Keep all strong callback work -on the supplied transaction connection. +An empty dispatch invokes no subscription. -## Fire and forget +## After commit -Use the shared fire-and-forget consistency mode: +Use an after-commit subscription for best-effort observation: ```gleam let observer = - factos.new_subscription( - consistency: factos.FireAndForget, + factos.new_after_commit_subscription( handle: fn(connection, recorded) { registration_observer.handle(connection, recorded) }, ) ``` -After the final successful commit, the backend starts one process per -fire-and-forget subscription. Each process receives the builder's ordinary Pog -connection and invokes its callback for every accepted record in append order. -Different subscriptions and dispatches can run concurrently; there is no global -callback ordering guarantee. +Attach it with `factos.with_after_commit_subscriptions`. After the final +successful commit, `factos_pog` starts one process per subscription. Each +process receives the builder's ordinary Pog connection and invokes its callback +for every accepted record in append order. Dispatch does not wait for these callbacks. Their `Ok` or `Error` result cannot -change the committed dispatch. A returned `Error` is ignored and the process -continues with later records. A panic terminates that process and drops its -remaining records. - -Fire-and-forget has no historical catch-up, cursor, checkpoint, replay, retry, -or dead-letter policy. It is suitable only for best-effort work. +change the committed dispatch. A panic terminates that process and drops its +remaining records. After-commit work has no historical catch-up, cursor, +checkpoint, replay, retry, or dead-letter policy. ## Durable external work An email, HTTP request, or message publish cannot commit atomically with the -PostgreSQL event transaction. Fire-and-forget therefore cannot provide durable -external delivery. - -For durable effects, have a strong callback insert an application-owned outbox -row with the event transaction. A separate application worker delivers that row -with its own retry and idempotency policy. See -[Durable effects](durable-effects.html). +PostgreSQL event transaction. Durable work must first be represented by a +transactional database write, such as inserting a durable job through a strong +subscription. A separate worker performs the external operation with its own +retry and idempotency policy. See [Durable effects](durable-effects.html). diff --git a/backends/factos_pog/gleam.toml b/backends/factos_pog/gleam.toml index a53644e..d75835c 100644 --- a/backends/factos_pog/gleam.toml +++ b/backends/factos_pog/gleam.toml @@ -33,19 +33,16 @@ source = "./docs/durable-effects.md" [dependencies] factos = { path = "../.." } gleam_stdlib = ">= 1.0.0 and < 2.0.0" -gleam_erlang = ">= 1.0.0 and < 2.0.0" gleam_json = ">= 3.1.0 and < 4.0.0" gleam_otp = ">= 1.2.0 and < 2.0.0" pog = ">= 4.1.0 and < 5.0.0" -exception = ">= 2.1.1 and < 3.0.0" [dev_dependencies] envoy = ">= 1.0.0 and < 2.0.0" unitest = ">= 1.0.0 and < 2.0.0" gleeunit = ">= 1.0.0 and < 2.0.0" -testcontainer = ">= 1.0.2 and < 2.0.0" -testcontainer_formulas = ">= 1.0.0 and < 2.0.0" simplifile = ">= 2.5.0 and < 3.0.0" youid = ">= 1.5.4 and < 2.0.0" gleamy_bench = ">= 0.6.0 and < 1.0.0" global_value = ">= 1.0.0 and < 2.0.0" +gleam_erlang = ">= 1.3.0 and < 2.0.0" diff --git a/backends/factos_pog/manifest.toml b/backends/factos_pog/manifest.toml index 245c73f..244acc4 100644 --- a/backends/factos_pog/manifest.toml +++ b/backends/factos_pog/manifest.toml @@ -10,11 +10,9 @@ packages = [ { name = "argv", version = "1.1.0", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "3277D100448BDB4A29B6D58C0F36F631CBC349E8BDD09766C6309DF202831140" }, { name = "backoff", version = "1.1.6", build_tools = ["rebar3"], requirements = [], otp_app = "backoff", source = "hex", outer_checksum = "CF0CFFF8995FB20562F822E5CC47D8CCF664C5ECDC26A684CBE85C225F9D7C39" }, { name = "clip", version = "1.2.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "clip", source = "hex", outer_checksum = "B903008782C62B84FD3C2855EDE4805F7F9B545F2D63823953CED33EDA113301" }, - { 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 = "envoy", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "9C6FBB6BFA02A52798BEEC5977A738CAD6E4A057F4B67FD0C8061AD2502C191A" }, { name = "exception", version = "2.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "6BDEA95248093599391C3B5DF1835C5C6A86C353C2F99CE539B450E3432FE117" }, - { name = "factos", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], source = "local", path = "../.." }, + { name = "factos", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], source = "local", path = "../.." }, { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, { name = "glance", version = "6.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "glexer"], otp_app = "glance", source = "hex", outer_checksum = "9037EBBAD2A220CD46DADEDCDF9DFAC7406FD2959535334473E60C32F170622A" }, { name = "gleam_community_ansi", version = "1.5.0", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_regexp", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "B5AA433AF84313E23FDF90CCFF752B9380FE9FFCE02B2949D49B7AACCC77B16D" }, @@ -22,10 +20,10 @@ packages = [ { 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_otp", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "DE4CA6850842F0266EE95317A25DD6A0A0F20CDFAB7C0ADC2E63251D7C3C72EC" }, { name = "gleam_regexp", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_regexp", source = "hex", outer_checksum = "9C215C6CA84A5B35BB934A9B61A9A306EC743153BE2B0425A0D032E477B062A9" }, - { name = "gleam_stdlib", version = "1.0.3", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "1F543AFBA5D33DA493E6087F4E4C4F20D899411343512686C98A8ABB2963CF22" }, - { name = "gleam_time", version = "1.8.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "533D8723774D61AD4998324F5DD1DABDCDBFABAFB9E87CB5D03C6955448FC97D" }, + { 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.10.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "56539216E4C4B1748714652AB38F0BD16B9101F61DB62769FDC7CD42A8E5E833" }, { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, { name = "gleamy_bench", version = "0.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleamy_bench", source = "hex", outer_checksum = "DEF68E4B097A56781282F0F9D48371A0ABBCDDCF89CAD05B28C3BEDD6B2E8DF3" }, { name = "glearray", version = "2.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "glearray", source = "hex", outer_checksum = "1554E48DD40114D7602F5BFF4D7278B6B3B735F137C7FDEEADFB2FE7951C94BE" }, @@ -38,12 +36,10 @@ packages = [ { name = "pog", version = "4.1.0", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_otp", "gleam_stdlib", "gleam_time", "pgo"], otp_app = "pog", source = "hex", outer_checksum = "E4AFBA39A5FAA2E77291836C9683ADE882E65A06AB28CA7D61AE7A3AD61EBBD5" }, { name = "prng", version = "5.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "prng", source = "hex", outer_checksum = "29DA88BCFB54D06DD1472951DF101E9524878056D139DA2616B04350B403CE10" }, { name = "repeatedly", version = "2.1.2", build_tools = ["gleam"], requirements = [], otp_app = "repeatedly", source = "hex", outer_checksum = "93AE1938DDE0DC0F7034F32C1BF0D4E89ACEBA82198A1FE21F604E849DA5F589" }, - { name = "simplifile", version = "2.5.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "6C72DCCDF25C38A5931740B30E823969F33106831FD1637719B5EDBCA30027A4" }, + { name = "simplifile", version = "2.7.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "A2727627B063E87351934C7F7F008F2D1FDB16F6DE0B8C79F9E46459CFC9C164" }, { name = "spinner", version = "1.3.1", build_tools = ["gleam"], requirements = ["gleam_community_ansi", "gleam_stdlib", "glearray", "repeatedly"], otp_app = "spinner", source = "hex", outer_checksum = "21BDE7FF9D7D9ACBB4086C0D5C86F0A90CE6B0F3CB593B41D03384AE7724B5B4" }, { name = "splitter", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "splitter", source = "hex", outer_checksum = "3DFD6B6C49E61EDAF6F7B27A42054A17CFF6CA2135FF553D0CB61C234D281DD0" }, { name = "string_width", version = "3.4.3", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "string_width", source = "hex", outer_checksum = "EC2D012CC99CAE1395776BBDF1CD6E45CA4E08210F7AADD7C40D0E1222EFBE4A" }, - { 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 = "tobble", version = "2.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_yielder", "string_width"], otp_app = "tobble", source = "hex", outer_checksum = "95E7656A560964E4627ADD148675FB5808F8F5D262CAB1DF0F6375B85487CDBA" }, { name = "unitest", version = "1.6.0", build_tools = ["gleam"], requirements = ["argv", "clip", "envoy", "glance", "gleam_community_ansi", "gleam_stdlib", "gleam_time", "prng", "simplifile", "spinner", "tobble"], otp_app = "unitest", source = "hex", outer_checksum = "1743ED721F7CFF13CB06B359B1C20ADD74AB39AFE0CDF404526ABC2B0DB69C2B" }, { name = "youid", version = "1.6.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_stdlib", "gleam_time"], otp_app = "youid", source = "hex", outer_checksum = "7A3ABA44B1B38BC2BDCB5474C5317AA372BE58DFBC649815EE08B03526DDA18D" }, @@ -51,9 +47,8 @@ packages = [ [requirements] envoy = { version = ">= 1.0.0 and < 2.0.0" } -exception = { version = ">= 2.1.1 and < 3.0.0" } factos = { path = "../.." } -gleam_erlang = { version = ">= 1.0.0 and < 2.0.0" } +gleam_erlang = { version = ">= 1.3.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" } @@ -62,7 +57,5 @@ gleeunit = { version = ">= 1.0.0 and < 2.0.0" } global_value = { version = ">= 1.0.0 and < 2.0.0" } pog = { version = ">= 4.1.0 and < 5.0.0" } 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" } unitest = { version = ">= 1.0.0 and < 2.0.0" } youid = { version = ">= 1.5.4 and < 2.0.0" } diff --git a/backends/factos_pog/src/factos/factos_pog.gleam b/backends/factos_pog/src/factos/factos_pog.gleam index ba7f4e5..6323836 100644 --- a/backends/factos_pog/src/factos/factos_pog.gleam +++ b/backends/factos_pog/src/factos/factos_pog.gleam @@ -14,11 +14,9 @@ //// data without understanding the domain payload, so any payload value needed //// for context selection must also be written as a tag. -import exception import factos import gleam/dict import gleam/dynamic/decode -import gleam/erlang/process import gleam/int import gleam/json import gleam/list @@ -26,15 +24,46 @@ import gleam/result import gleam/string import pog -pub type Decoder(event) = - fn(factos.Recorded(String)) -> Result(event, factos.Recorded(String)) +pub type Configuration( + command, + state, + event, + delivery, + transaction, + domain_error, + subscription_error, +) { + Configuration( + model: factos.Model(command, state, event, domain_error), + connection: pog.Connection, + retry_attempts: Int, + subscriptions: List( + factos.Subscription(delivery, subscription_error, transaction), + ), + ) +} + +pub fn configure( + model model: factos.Model(command, state, event, domain_error), + connection connection: pog.Connection, +) -> Configuration( + command, + state, + event, + delivery, + transaction, + domain_error, + subscription_error, +) { + Configuration(model:, connection:, retry_attempts: 5, subscriptions: []) +} pub type Error(domain_error, subscription_error) = factos.Error( domain_error, subscription_error, pog.QueryError, - factos.Recorded(String), + json.DecodeError, ) type QuerySql { @@ -56,54 +85,35 @@ type PreparedEvent(event) { /// Dispatch a command and atomically append its accepted events. pub fn dispatch( - builder: factos.DispatchBuilder( + configuration: Configuration( command, state, event, - json.Json, - String, + factos.Recorded(event), + pog.Connection, domain_error, subscription_error, - pog.Connection, - factos.Recorded(String), ), - command: command, + command command: command, + decision_context decision_context: factos.DecisionContext, event_id event_id: fn() -> String, ) -> Result(factos.Dispatch(event), Error(domain_error, subscription_error)) { - let factos.DispatchBuilder( - connection:, - decision_context:, - decider:, - encode:, - decode:, - retry_attempts:, - subscriptions:, - ) = builder - - let result = - dispatch_context( - connection, - decision_context:, - decider:, - encode:, - decode:, - command:, - event_id:, - retry_attempts:, - subscriptions:, - ) + let Configuration(model:, connection:, retry_attempts:, subscriptions:) = + configuration + let factos.Model(decider:, encode:, decode:) = model + let decider = decider(command) - case result { - Error(error) -> Error(error) - Ok(dispatch) -> { - enqueue_fire_and_forget_subscriptions( - connection, - subscriptions, - dispatch.events, - ) - Ok(dispatch) - } - } + dispatch_context( + connection, + decision_context, + decider, + encode, + decode, + command, + event_id, + retry_attempts, + subscriptions, + ) } @internal @@ -111,13 +121,11 @@ pub fn read( connection: pog.Connection, decision_context: factos.DecisionContext, decider: factos.Decider(command, state, event, domain_error), - decode: fn(factos.Recorded(String)) -> Result(event, factos.Recorded(String)), + decode: fn(factos.EventType, Int) -> Result(decode.Decoder(event), Nil), ) -> Result( factos.Context(event, state), Error(domain_error, subscription_error), ) { - let factos.Decider(initial:, evolve:, ..) = decider - use events <- result.try(read_matching_events( connection, decision_context, @@ -127,7 +135,7 @@ pub fn read( Ok(factos.Context( decision_context:, - state: factos.evolve_recorded(initial:, events:, evolve:), + state: factos.evolve_recorded(decider, events), events:, position:, append_condition: factos.FailIfEventsMatch( @@ -143,7 +151,7 @@ pub fn read_after( decision_context: factos.DecisionContext, after: factos.SequencePosition, limit: Int, - decode: Decoder(event), + decode: fn(factos.EventType, Int) -> Result(decode.Decoder(event), Nil), ) -> Result( List(factos.Recorded(event)), Error(domain_error, subscription_error), @@ -169,21 +177,26 @@ pub fn read_after( |> pog.execute(on: connection) |> result.map(fn(returned) { returned.rows }) |> result.map_error(factos.StoreError) + |> result.try(result.all) } } } fn dispatch_context( connection: pog.Connection, - decision_context decision_context: factos.DecisionContext, - decider decider: factos.Decider(command, state, event, domain_error), - encode encode: fn(event) -> factos.Event(json.Json), - decode decode: Decoder(event), - command command: command, - event_id event_id: fn() -> String, - retry_attempts retry_attempts: Int, - subscriptions subscriptions: List( - factos.Subscription(event, subscription_error, pog.Connection), + decision_context: factos.DecisionContext, + decider: factos.Decider(command, state, event, domain_error), + encode: fn(event) -> factos.Event(json.Json), + decode: fn(factos.EventType, Int) -> Result(decode.Decoder(event), Nil), + command: command, + event_id: fn() -> String, + retry_attempts: Int, + subscriptions: List( + factos.Subscription( + factos.Recorded(event), + subscription_error, + pog.Connection, + ), ), ) -> Result(factos.Dispatch(event), Error(domain_error, subscription_error)) { use transaction_connection <- run_serializable_transaction( @@ -196,11 +209,10 @@ fn dispatch_context( decider, decode, )) - use pair <- result.try( + use events <- result.try( factos.decide_context(context, command, decider) |> result.map_error(factos.DomainError), ) - let #(context, events) = pair use dispatch <- result.try(append_events( transaction_connection, events, @@ -274,7 +286,7 @@ fn set_serializable_isolation( ) -> Result(Nil, Error(domain_error, subscription_error)) { pog.query("set transaction isolation level serializable") |> pog.execute(on: connection) - |> result.map(nil_constant) + |> result.replace(Nil) |> result.map_error(factos.StoreError) } @@ -282,92 +294,51 @@ fn retryable_transaction_error( error: Error(domain_error, subscription_error), ) -> Bool { case error { - factos.StoreError(pog.PostgresqlError(code: "40001", ..)) -> True - factos.StoreError(pog.PostgresqlError(code: "40P01", ..)) -> True - factos.DomainError(_) -> False - factos.SubscriptionError(error: _) -> False - factos.StoreError(_) -> False - factos.AppendConditionFailed(_) -> False - factos.DecodeError(_) -> False + factos.StoreError(pog.PostgresqlError(code: "40001", ..)) + | factos.StoreError(pog.PostgresqlError(code: "40P01", ..)) -> True + factos.DomainError(_) + | factos.SubscriptionError(_) + | factos.StoreError(_) + | factos.AppendConditionFailed(_) + | factos.DecodeError(_) + | factos.InvalidSchema(_, _) -> False } } fn run_strong_subscriptions( connection: pog.Connection, subscriptions: List( - factos.Subscription(event, subscription_error, pog.Connection), + factos.Subscription( + factos.Recorded(event), + subscription_error, + pog.Connection, + ), ), events: List(factos.Recorded(event)), -) -> Result(Nil, Error(domain_error, subscription_error)) { +) -> Result(pog.Connection, Error(domain_error, subscription_error)) { case subscriptions { - [] -> Ok(Nil) - [factos.Subscription(consistency:, handle:), ..remaining] -> - case consistency { - factos.FireAndForget -> - run_strong_subscriptions(connection, remaining, events) - factos.StrongConsistency -> { - use _ <- result.try( - run_strong_subscription_events(connection, handle, events) - |> result.map_error(factos.SubscriptionError), - ) - run_strong_subscriptions(connection, remaining, events) - } - } - } -} - -fn run_strong_subscription_events( - connection: pog.Connection, - handle: fn(pog.Connection, factos.Recorded(event)) -> - Result(Nil, subscription_error), - events: List(factos.Recorded(event)), -) -> Result(Nil, subscription_error) { - case events { - [] -> Ok(Nil) - [recorded, ..remaining] -> { - use Nil <- result.try(handle(connection, recorded)) - run_strong_subscription_events(connection, handle, remaining) - } - } -} - -fn enqueue_fire_and_forget_subscriptions( - connection: pog.Connection, - subscriptions: List( - factos.Subscription(event, subscription_error, pog.Connection), - ), - events: List(factos.Recorded(event)), -) -> Nil { - use subscription <- list.each(subscriptions) - case subscription.consistency { - factos.StrongConsistency -> Nil - factos.FireAndForget -> { - case events { - [] -> Nil - events -> { - let _ = { - use <- exception.rescue - use <- process.spawn() - run_fire_and_forget_events(connection, events, subscription.handle) - } - Nil - } - } + [] -> Ok(connection) + [factos.Subscription(apply:), ..remaining] -> { + use connection <- result.try( + run_strong_subscription_events(connection, apply, events) + |> result.map_error(factos.SubscriptionError), + ) + run_strong_subscriptions(connection, remaining, events) } } } -fn run_fire_and_forget_events( +fn run_strong_subscription_events( connection: pog.Connection, + apply: fn(pog.Connection, factos.Recorded(event)) -> + Result(pog.Connection, subscription_error), events: List(factos.Recorded(event)), - handle: fn(pog.Connection, factos.Recorded(event)) -> - Result(Nil, subscription_error), -) -> Nil { +) -> Result(pog.Connection, subscription_error) { case events { - [] -> Nil + [] -> Ok(connection) [recorded, ..remaining] -> { - let _ = handle(connection, recorded) - run_fire_and_forget_events(connection, remaining, handle) + use connection <- result.try(apply(connection, recorded)) + run_strong_subscription_events(connection, apply, remaining) } } } @@ -467,7 +438,8 @@ fn insert_event_batch( )) |> pog.parameter(pog.array( fn(event: PreparedEvent(event)) { - pog.text(factos.event_type_to_string(event.event.descriptor.type_)) + let factos.EventType(type_) = event.event.descriptor.type_ + pog.text(type_) }, events, )) @@ -485,7 +457,11 @@ fn insert_event_batch( )) |> pog.parameter(pog.array( fn(event: PreparedEvent(event)) { - pog.text(metadata_to_json(event.event.descriptor.metadata)) + pog.text( + json.to_string(factos.metadata_to_json( + event.event.descriptor.metadata, + )), + ) }, events, )) @@ -541,7 +517,7 @@ fn append_condition_to_sql(condition: factos.AppendCondition) -> QuerySql { fn read_matching_events( connection: pog.Connection, decision_context: factos.DecisionContext, - decode: Decoder(event), + decode: fn(factos.EventType, Int) -> Result(decode.Decoder(event), Nil), ) -> Result( List(factos.Recorded(event)), Error(domain_error, subscription_error), @@ -556,19 +532,34 @@ fn read_matching_events( |> pog.execute(on: connection) |> result.map(fn(returned) { returned.rows }) |> result.map_error(factos.StoreError) + |> result.try(result.all) } fn stored_row_decoder( - decode: Decoder(event), -) -> decode.Decoder(factos.Recorded(event)) { + decode: fn(factos.EventType, Int) -> Result(decode.Decoder(event), Nil), +) -> decode.Decoder(Result(factos.Recorded(event), Error(domain_error, _))) { use position <- decode.field(0, decode.int) use id <- decode.field(1, decode.string) - use type_ <- decode.field(2, decode.string |> decode.map(factos.event_type)) + use type_ <- decode.field(2, decode.string |> decode.map(factos.EventType)) use version <- decode.field(3, decode.int) use tags <- decode.field(4, tags_column_decoder()) use metadata <- decode.field(5, metadata_column_decoder()) - use payload <- decode.field(6, decode.string) - let string_event = + + use payload <- decode.field(6, { + use payload <- decode.then(decode.string) + let decoder = + decode(type_, version) + |> result.replace_error(factos.InvalidSchema(type_, version)) + + decode.success({ + use decoder <- result.try(decoder) + json.parse(payload, decoder) + |> result.map_error(factos.DecodeError) + }) + }) + + decode.success({ + use payload <- result.map(payload) factos.Recorded( id:, position: factos.SequencePosition(position), @@ -577,21 +568,9 @@ fn stored_row_decoder( payload:, ), ) - case decode(string_event) { - Ok(payload) -> - decode.success( - factos.Recorded( - ..string_event, - event: factos.Event(..string_event.event, payload:), - ), - ) - Error(error) -> decode.failure(coerce(error), expected: "Decodable payload") - } + }) } -@external(erlang, "gleam", "identity") -fn coerce(a: a) -> b - fn metadata_column_decoder() -> decode.Decoder(factos.Metadata) { use value <- decode.then(decode.string) case json.parse(value, decode.dict(decode.string, decode.string)) { @@ -716,7 +695,8 @@ fn types_to_sql( <> placeholders(parameter_index, list.length(types)) <> ")", parameters: list.map(types, fn(type_) { - TextParameter(factos.event_type_to_string(type_)) + let factos.EventType(type_) = type_ + TextParameter(type_) }), ) } @@ -738,7 +718,8 @@ fn tags_to_sql(tags: List(factos.Tag), parameter_index: Int) -> QuerySql { QuerySql( sql: "(" <> string.join(clauses, with: " and ") <> ")", parameters: list.map(tags, fn(tag) { - TextParameter(factos.tag_value(tag)) + let factos.Tag(tag) = tag + TextParameter(tag) }), ) } @@ -781,7 +762,10 @@ fn with_parameters( fn tags_to_json(tags: List(factos.Tag)) -> String { tags - |> list.map(factos.tag_value) + |> list.map(fn(tag) { + let factos.Tag(tag) = tag + tag + }) |> json.array(json.string) |> json.to_string } @@ -792,7 +776,7 @@ fn tags_column_decoder() -> decode.Decoder(List(factos.Tag)) { case json.parse( tags, - using: decode.string |> decode.map(factos.tag) |> decode.list, + using: decode.string |> decode.map(factos.Tag) |> decode.list, ) { Ok(tags) -> decode.success(tags) @@ -803,20 +787,8 @@ fn tags_column_decoder() -> decode.Decoder(List(factos.Tag)) { tags |> string.split(on: "\n") |> list.filter(fn(tag) { !string.is_empty(tag) }) - |> list.map(factos.tag) + |> list.map(factos.Tag) |> decode.success } } } - -fn metadata_to_json(metadata: factos.Metadata) -> String { - metadata - |> factos.metadata_entries - |> list.map(fn(entry) { #(entry.0, json.string(entry.1)) }) - |> json.object - |> json.to_string -} - -fn nil_constant(_: a) -> Nil { - Nil -} diff --git a/backends/factos_pog/test/factos_pog_test.gleam b/backends/factos_pog/test/factos_pog_test.gleam index 38bc487..e4adf81 100644 --- a/backends/factos_pog/test/factos_pog_test.gleam +++ b/backends/factos_pog/test/factos_pog_test.gleam @@ -71,14 +71,6 @@ type ProjectionBarrierMessage { ) } -type FireSubscriptionMessage { - FireSubscriptionStarted( - pid: process.Pid, - event: factos.Recorded(Event), - release: process.Subject(Nil), - ) -} - type SubscriptionDispatchMessage { SubscriptionDispatchFinished( result: Result( @@ -145,21 +137,19 @@ pub fn dbmate_upgrade_and_v2_rollback_preserve_contract_test() { decode, ) assert event.payload == UserRegistered(username: "renata") - assert factos.metadata_get(event.descriptor.metadata, factos.correlation_id) - == Ok("migration-correlation") + assert event.descriptor.metadata + == factos.metadata([#("correlation_id", "migration-correlation")]) assert_uuidv4_identity_contract(connection) assert_event_store_objects(connection) let assert Ok(_v2_dispatch) = - factos.new_dispatch( + factos_pog.configure( connection:, - decision_context: factos.NoContext, - decider: decider(), - encode:, - decode:, + model: factos.model(decider:, encode:, decode:), ) |> factos_pog.dispatch( RegisterUser(username: "v2"), + decision_context: factos.NoContext, event_id: uuid.v4_string, ) execute_dbmate_down(connection, schema_migration) @@ -212,89 +202,6 @@ pub fn dbmate_upgrade_and_v2_rollback_preserve_contract_test() { assert removed_objects.rows == ["", "", ""] } -pub fn fire_and_forget_subscription_runs_after_commit_without_blocking_test() { - use connection <- with_test_connection() - reset_schema(connection) - reset_subscription_test_state(connection) - let strong_barriers = process.new_subject() - let fire_deliveries = process.new_subject() - let dispatch_results = process.new_subject() - - let subscriptions = [ - blocking_strong_subscription(strong_barriers, name: "strong"), - blocking_fire_subscription(fire_deliveries), - ] - - let _first_dispatch_pid = - start_subscription_dispatch_worker( - connection, - username: "renata", - subscriptions:, - results: dispatch_results, - ) - let #(first_strong_event, first_strong_release) = - receive_projection_barrier(strong_barriers, expected_name: "strong") - - assert all_recorded_events(connection) == [] - assert projected_users(connection) == [] - let assert Error(Nil) = process.receive(dispatch_results, within: 100) - let assert Error(Nil) = process.receive(fire_deliveries, within: 100) - - process.send(first_strong_release, Nil) - let assert Ok(first_dispatch) = - receive_subscription_dispatch(dispatch_results) - assert all_recorded_events(connection) == first_dispatch.events - let assert [first_recorded] = first_dispatch.events - assert first_strong_event == first_recorded - let assert [#(first_projection_id, "renata")] = projected_users(connection) - assert first_projection_id == first_recorded.id - - let FireSubscriptionStarted( - pid: first_fire_pid, - event: first_fire_event, - release: first_fire_release, - ) = receive_fire_subscription(fire_deliveries) - assert [first_fire_event] == first_dispatch.events - assert process.is_alive(first_fire_pid) - let first_fire_monitor = process.monitor(first_fire_pid) - process.send(first_fire_release, Nil) - wait_for_monitor(first_fire_monitor) - - // A returned fire callback error cannot change the already committed result. - assert all_recorded_events(connection) == first_dispatch.events - assert projected_users(connection) == [#(first_projection_id, "renata")] - - let _second_dispatch_pid = - start_subscription_dispatch_worker( - connection, - username: "maria", - subscriptions:, - results: dispatch_results, - ) - let #(second_strong_event, second_strong_release) = - receive_projection_barrier(strong_barriers, expected_name: "strong") - process.send(second_strong_release, Nil) - let assert Ok(second_dispatch) = - receive_subscription_dispatch(dispatch_results) - let assert [second_recorded] = second_dispatch.events - assert second_strong_event == second_recorded - - let FireSubscriptionStarted( - pid: second_fire_pid, - event: second_fire_event, - release: second_fire_release, - ) = receive_fire_subscription(fire_deliveries) - assert second_fire_event == second_recorded - assert process.is_alive(second_fire_pid) - let second_fire_monitor = process.monitor(second_fire_pid) - process.send(second_fire_release, Nil) - wait_for_monitor(second_fire_monitor) - - assert all_recorded_events(connection) - == list.append(first_dispatch.events, second_dispatch.events) - assert list.length(projected_users(connection)) == 2 -} - pub fn strong_subscription_commits_with_dispatch_test() { use connection <- with_test_connection() reset_schema(connection) @@ -307,7 +214,7 @@ pub fn strong_subscription_commits_with_dispatch_test() { start_subscription_dispatch_worker( connection, username: "renata", - subscriptions: [subscription], + strong_subscriptions: [subscription], results: dispatch_results, ) @@ -332,52 +239,38 @@ pub fn strong_subscription_failure_rolls_back_dispatch_test() { let fire_deliveries = process.new_subject() let insert_projection = - factos.new_subscription( - consistency: factos.StrongConsistency, - handle: insert_test_projection, - ) + factos.subscription(fn(connection, recorded) { + use Nil <- result.try(insert_test_projection(connection, recorded)) + Ok(connection) + }) let fail_after_observing_projection = - factos.new_subscription( - consistency: factos.StrongConsistency, - handle: fn(transaction_connection, recorded) { - let factos.Recorded(id:, ..) = recorded - case - projected_users(transaction_connection) - |> list.any(fn(row) { row.0 == id }) - { - True -> Error("expected strong failure") - False -> Error("earlier strong callback was not visible") - } - }, - ) - let fire_subscription = - factos.new_subscription( - consistency: factos.FireAndForget, - handle: fn(_connection, recorded) { - report_fire_event(fire_deliveries, recorded) - }, - ) + factos.subscription(fn(transaction_connection, recorded) { + let factos.Recorded(id:, ..) = recorded + case + projected_users(transaction_connection) + |> list.any(fn(row) { row.0 == id }) + { + True -> Error("expected strong failure") + False -> Error("earlier strong callback was not visible") + } + }) let result = - factos.new_dispatch( - connection:, - decision_context: username_decision_context("renata"), - decider: decider(), - encode:, - decode:, + factos_pog.Configuration( + ..factos_pog.configure( + factos.model(decider:, encode:, decode:), + connection:, + ), + subscriptions: [insert_projection, fail_after_observing_projection], ) - |> factos.with_subscriptions(subscriptions: [ - insert_projection, - fail_after_observing_projection, - fire_subscription, - ]) |> factos_pog.dispatch( RegisterUser(username: "renata"), + decision_context: username_decision_context("renata"), event_id: uuid.v4_string, ) let assert Error(dispatch_error) = result - let assert factos.SubscriptionError(error: "expected strong failure") = + let assert factos.SubscriptionError("expected strong failure") = dispatch_error assert all_recorded_events(connection) == [] assert projected_users(connection) == [] @@ -385,7 +278,7 @@ pub fn strong_subscription_failure_rolls_back_dispatch_test() { factos_pog.read( connection, username_decision_context("renata"), - decider(), + decider(RegisterUser("renata")), decode, ) assert context.state == Available @@ -397,62 +290,36 @@ pub fn strong_subscription_failure_rolls_back_dispatch_test() { fn blocking_strong_subscription( barriers: process.Subject(ProjectionBarrierMessage), name name: String, -) -> factos.Subscription(Event, String, pog.Connection) { - factos.new_subscription( - consistency: factos.StrongConsistency, - handle: fn(connection, recorded) { - use _ <- result.try(insert_test_projection(connection, recorded)) - block_projection(name, barriers, recorded) - }, - ) -} - -fn blocking_fire_subscription( - deliveries: process.Subject(FireSubscriptionMessage), -) -> factos.Subscription(Event, String, pog.Connection) { - factos.new_subscription( - consistency: factos.FireAndForget, - handle: fn(_connection, recorded) { - let release = process.new_subject() - process.send( - deliveries, - FireSubscriptionStarted(pid: process.self(), event: recorded, release:), - ) - case process.receive(release, within: 10_000) { - Ok(Nil) -> Error("ignored") - Error(Nil) -> Error("fire subscription release timed out") - } - }, - ) -} - -fn report_fire_event( - deliveries: process.Subject(factos.Recorded(Event)), - recorded: factos.Recorded(Event), -) -> Result(Nil, String) { - process.send(deliveries, recorded) - Ok(Nil) +) -> factos.Subscription(factos.Recorded(Event), String, pog.Connection) { + factos.subscription(fn(connection, recorded) { + use Nil <- result.try(insert_test_projection(connection, recorded)) + use Nil <- result.try(block_projection(name, barriers, recorded)) + Ok(connection) + }) } fn start_subscription_dispatch_worker( connection: pog.Connection, username username: String, - subscriptions subscriptions: List( - factos.Subscription(Event, String, pog.Connection), + strong_subscriptions strong_subscriptions: List( + factos.Subscription(factos.Recorded(Event), String, pog.Connection), ), results results: process.Subject(SubscriptionDispatchMessage), ) -> process.Pid { process.spawn(fn() { let result = - factos.new_dispatch( - connection:, + factos_pog.Configuration( + ..factos_pog.configure( + factos.model(decider:, encode:, decode:), + connection:, + ), + subscriptions: strong_subscriptions, + ) + |> factos_pog.dispatch( + RegisterUser(username:), decision_context: username_decision_context(username), - decider: decider(), - encode:, - decode:, + event_id: uuid.v4_string, ) - |> factos.with_subscriptions(subscriptions:) - |> factos_pog.dispatch(RegisterUser(username:), event_id: uuid.v4_string) process.send(results, SubscriptionDispatchFinished(result:)) }) } @@ -475,13 +342,6 @@ fn receive_projection_barrier( #(event, release) } -fn receive_fire_subscription( - deliveries: process.Subject(FireSubscriptionMessage), -) -> FireSubscriptionMessage { - let assert Ok(delivery) = process.receive(deliveries, within: 10_000) - delivery -} - fn all_recorded_events( connection: pog.Connection, ) -> List(factos.Recorded(Event)) { @@ -496,28 +356,19 @@ fn all_recorded_events( events } -fn wait_for_monitor(monitor: process.Monitor) -> Nil { - let assert Ok(Nil) = - process.new_selector() - |> process.select_specific_monitor(monitor, fn(_) { Nil }) - |> process.selector_receive(5000) - process.demonitor_process(monitor) -} - pub fn read_after_orders_filters_and_bounds_pages_test() { use connection <- with_test_connection() reset_schema(connection) ["renata", "maria", "lucy"] |> list.each(fn(username) { let assert Ok(_) = - factos.new_dispatch( - connection:, + factos.model(decider:, encode:, decode:) + |> factos_pog.configure(connection:) + |> factos_pog.dispatch( + RegisterUser(username), decision_context: username_decision_context(username), - decider: decider(), - encode:, - decode:, + event_id: uuid.v4_string, ) - |> factos_pog.dispatch(RegisterUser(username), event_id: uuid.v4_string) Nil }) @@ -569,14 +420,17 @@ pub fn multi_event_dispatch_uses_one_insert_statement_test() { install_event_insert_statement_counter(connection) let assert Ok(dispatch) = - factos.new_dispatch( - connection:, - decision_context: factos.NoContext, - decider: counter_decider(), + factos.model( + decider: fn(_) { counter_decider() }, encode: encode_counter_event, decode: decode_counter_event, ) - |> factos_pog.dispatch(IncrementTwice, event_id: uuid.v4_string) + |> factos_pog.configure(connection:) + |> factos_pog.dispatch( + IncrementTwice, + decision_context: factos.NoContext, + event_id: uuid.v4_string, + ) let assert [first, second] = dispatch.events assert first.event.payload == Incremented(1) @@ -586,8 +440,8 @@ pub fn multi_event_dispatch_uses_one_insert_statement_test() { let tagged_context = factos.Matching([ - factos.item(types: [factos.event_type("Incremented")], tags: [ - factos.tag("counter:load"), + factos.Item(types: [factos.EventType("Incremented")], tags: [ + factos.Tag("counter:load"), ]), ]) let assert Ok(stored_events) = @@ -638,17 +492,18 @@ fn event_insert_statement_count(connection: pog.Connection) -> Int { pub fn dispatch_builder_with_one_retry_attempt_persists_events_test() { use connection <- with_test_connection() reset_schema(connection) + let model = factos.model(decider:, encode:, decode:) let assert Ok(dispatch) = - factos.new_dispatch( - connection: connection, + factos_pog.Configuration( + ..factos_pog.configure(model, connection: connection), + retry_attempts: 1, + ) + |> factos_pog.dispatch( + RegisterUser("renata"), decision_context: username_decision_context("renata"), - decider: decider(), - encode:, - decode:, + event_id: uuid.v4_string, ) - |> factos.with_retry_attempts(attempts: 1) - |> factos_pog.dispatch(RegisterUser("renata"), event_id: uuid.v4_string) let assert factos.SequencePosition(_) = dispatch.position let assert [recorded] = dispatch.events @@ -662,7 +517,7 @@ pub fn dispatch_builder_with_one_retry_attempt_persists_events_test() { factos_pog.read( connection, username_decision_context("renata"), - decider(), + decider(RegisterUser("renata")), decode, ) @@ -703,7 +558,7 @@ pub fn concurrent_dispatch_same_context_allows_one_empty_state_append_test() { factos_pog.read( connection, username_decision_context("renata"), - decider(), + decider(RegisterUser("renata")), decode, ) assert context.state == Taken @@ -722,9 +577,8 @@ pub fn concurrent_dispatch_with_decision_context_retries_duplicate_username_test let messages = process.new_subject() let projection_barriers = process.new_subject() let fire_deliveries = process.new_subject() - let subscriptions = [ + let strong_subscriptions = [ concurrent_strong_subscription(projection_barriers), - concurrent_fire_subscription(fire_deliveries), ] start_blocked_decision_context_dispatch_worker( connection, @@ -732,7 +586,7 @@ pub fn concurrent_dispatch_with_decision_context_retries_duplicate_username_test worker: "first", decision_context:, command: RegisterUser(username: "renata"), - subscriptions:, + strong_subscriptions:, ) start_blocked_decision_context_dispatch_worker( connection, @@ -740,7 +594,7 @@ pub fn concurrent_dispatch_with_decision_context_retries_duplicate_username_test worker: "second", decision_context:, command: RegisterUser(username: "renata"), - subscriptions:, + strong_subscriptions:, ) let first_release = receive_dispatch_ready(messages) @@ -783,13 +637,16 @@ pub fn concurrent_dispatch_with_decision_context_retries_duplicate_username_test assert all_recorded_events(connection) == committed_dispatch.events assert projected_users(connection) == [#(committed_attempt.id, "renata")] - let assert Ok(fire_event) = process.receive(fire_deliveries, within: 10_000) - assert fire_event == committed_attempt let assert Error(Nil) = process.receive(fire_deliveries, within: 200) let assert Error(Nil) = process.receive(projection_barriers, within: 200) let assert Ok(context) = - factos_pog.read(connection, decision_context, decider(), decode) + factos_pog.read( + connection, + decision_context, + decider(RegisterUser("renata")), + decode, + ) assert context.state == Taken assert context.events == [committed_attempt] } @@ -831,27 +688,12 @@ fn install_one_commit_serialization_failure(connection: pog.Connection) -> Nil { fn concurrent_strong_subscription( barriers: process.Subject(ProjectionBarrierMessage), -) -> factos.Subscription(Event, Nil, pog.Connection) { - factos.new_subscription( - consistency: factos.StrongConsistency, - handle: fn(connection, recorded) { - let assert Ok(Nil) = insert_test_projection(connection, recorded) - let assert Ok(Nil) = block_projection(recorded.id, barriers, recorded) - Ok(Nil) - }, - ) -} - -fn concurrent_fire_subscription( - deliveries: process.Subject(factos.Recorded(Event)), -) -> factos.Subscription(Event, Nil, pog.Connection) { - factos.new_subscription( - consistency: factos.FireAndForget, - handle: fn(_connection, recorded) { - process.send(deliveries, recorded) - Ok(Nil) - }, - ) +) -> factos.Subscription(factos.Recorded(Event), Nil, pog.Connection) { + factos.subscription(fn(connection, recorded) { + let assert Ok(Nil) = insert_test_projection(connection, recorded) + let assert Ok(Nil) = block_projection(recorded.id, barriers, recorded) + Ok(connection) + }) } pub fn dispatch_builder_with_query_filters_before_decoding_unknown_events_test() { @@ -862,14 +704,13 @@ pub fn dispatch_builder_with_query_filters_before_decoding_unknown_events_test() let decision_context = username_decision_context("renata") let assert Ok(dispatch) = - factos.new_dispatch( - connection: connection, + factos.model(decider:, encode:, decode:) + |> factos_pog.configure(connection:) + |> factos_pog.dispatch( + RegisterUser("renata"), decision_context:, - decider: decider(), - encode:, - decode:, + event_id: uuid.v4_string, ) - |> factos_pog.dispatch(RegisterUser("renata"), event_id: uuid.v4_string) let assert factos.SequencePosition(_) = dispatch.position let assert [recorded] = dispatch.events @@ -880,7 +721,12 @@ pub fn dispatch_builder_with_query_filters_before_decoding_unknown_events_test() ) let assert Ok(context) = - factos_pog.read(connection, decision_context, decider(), decode) + factos_pog.read( + connection, + decision_context, + decider(RegisterUser("renata")), + decode, + ) let assert [event] = context.events assert event.event.payload == UserRegistered("renata") @@ -895,8 +741,8 @@ pub fn dispatch_builder_with_query_handles_many_events_test() { let query = factos.Matching([ - factos.item(types: [factos.event_type("Incremented")], tags: [ - factos.tag("counter:load"), + factos.Item(types: [factos.EventType("Incremented")], tags: [ + factos.Tag("counter:load"), ]), ]) @@ -907,7 +753,7 @@ pub fn dispatch_builder_with_query_handles_many_events_test() { recorded, position: dispatch.position, value: 25, - type_: factos.event_type("Incremented"), + type_: factos.EventType("Incremented"), ) let assert Ok(context) = @@ -924,39 +770,27 @@ pub fn context_semantics_conformance_test() { let decision_context = empty_query() let assert Ok(renata_dispatch) = - factos.new_dispatch( - connection:, - decision_context:, - decider: accepting_decider(), - encode:, - decode:, - ) + factos.model(decider: fn(_) { accepting_decider() }, encode:, decode:) + |> factos_pog.configure(connection:) |> factos_pog.dispatch( RegisterUser(username: "renata"), + decision_context:, event_id: uuid.v4_string, ) let assert Ok(lucy_dispatch) = - factos.new_dispatch( - connection:, - decision_context:, - decider: accepting_decider(), - encode:, - decode:, - ) + factos.model(decider: fn(_) { accepting_decider() }, encode:, decode:) + |> factos_pog.configure(connection:) |> factos_pog.dispatch( RegisterUser(username: "lucy"), + decision_context:, event_id: uuid.v4_string, ) let assert Ok(marc_dispatch) = - factos.new_dispatch( - connection:, - decision_context: factos.AllEvents, - decider: accepting_decider(), - encode:, - decode:, - ) + factos.model(decider: fn(_) { accepting_decider() }, encode:, decode:) + |> factos_pog.configure(connection:) |> factos_pog.dispatch( RegisterUser(username: "marc"), + decision_context: factos.AllEvents, event_id: uuid.v4_string, ) let renata_position = renata_dispatch.position @@ -1005,36 +839,33 @@ pub fn empty_matching_context_dispatch_is_a_no_op_test() { reset_schema(connection) let assert Ok(seeded_dispatch) = - factos.new_dispatch( - connection:, - decision_context: username_decision_context("no-op"), - decider: decider(), - encode:, - decode:, - ) + factos.model(decider:, encode:, decode:) + |> factos_pog.configure(connection:) |> factos_pog.dispatch( RegisterUser(username: "no-op"), + decision_context: username_decision_context("no-op"), event_id: uuid.v4_string, ) let assert [seeded] = seeded_dispatch.events let assert Ok(no_op_dispatch) = - factos.new_dispatch( - connection:, - decision_context: factos.AllEvents, - decider: empty_decider(), - encode:, - decode:, - ) + factos.model(decider: fn(_) { empty_decider() }, encode:, decode:) + |> factos_pog.configure(connection:) |> factos_pog.dispatch( RegisterUser(username: "ignored"), + decision_context: factos.AllEvents, event_id: uuid.v4_string, ) assert no_op_dispatch.events == [] assert no_op_dispatch.position == factos.NoPosition let assert Ok(context) = - factos_pog.read(connection, factos.AllEvents, decider(), decode) + factos_pog.read( + connection, + factos.AllEvents, + decider(RegisterUser("no-op")), + decode, + ) assert context.events == [seeded] assert context.state == Taken assert context.position == seeded.position @@ -1046,27 +877,19 @@ pub fn no_context_dispatch_skips_existing_events_test() { reset_schema(connection) let assert Ok(first_dispatch) = - factos.new_dispatch( - connection:, - decision_context: username_decision_context("renata"), - decider: decider(), - encode:, - decode:, - ) + factos.model(decider:, encode:, decode:) + |> factos_pog.configure(connection:) |> factos_pog.dispatch( RegisterUser(username: "renata"), + decision_context: username_decision_context("renata"), event_id: uuid.v4_string, ) let assert Ok(second_dispatch) = - factos.new_dispatch( - connection:, - decision_context: factos.NoContext, - decider: decider(), - encode:, - decode:, - ) + factos.model(decider:, encode:, decode:) + |> factos_pog.configure(connection:) |> factos_pog.dispatch( RegisterUser(username: "renata"), + decision_context: factos.NoContext, event_id: uuid.v4_string, ) @@ -1097,14 +920,17 @@ fn start_blocked_dispatch_worker( } process.spawn(fn() { let result = - factos.new_dispatch( - connection:, - decision_context:, - decider: blocking_decider(messages, worker:), + factos.model( + decider: fn(_) { blocking_decider(messages, worker:) }, encode:, decode:, ) - |> factos_pog.dispatch(command, event_id: uuid.v4_string) + |> factos_pog.configure(connection:) + |> factos_pog.dispatch( + command, + decision_context:, + event_id: uuid.v4_string, + ) process.send(messages, DispatchFinished(worker:, result:)) }) } @@ -1115,21 +941,28 @@ fn start_blocked_decision_context_dispatch_worker( worker worker: String, decision_context decision_context: factos.DecisionContext, command command: Command, - subscriptions subscriptions: List( - factos.Subscription(Event, Nil, pog.Connection), + strong_subscriptions strong_subscriptions: List( + factos.Subscription(factos.Recorded(Event), Nil, pog.Connection), ), ) -> process.Pid { process.spawn(fn() { let result = - factos.new_dispatch( - connection:, + factos_pog.Configuration( + ..factos_pog.configure( + factos.model( + decider: fn(_) { blocking_decider(messages, worker:) }, + encode:, + decode:, + ), + connection:, + ), + subscriptions: strong_subscriptions, + ) + |> factos_pog.dispatch( + command, decision_context:, - decider: blocking_decider(messages, worker:), - encode:, - decode:, + event_id: uuid.v4_string, ) - |> factos.with_subscriptions(subscriptions:) - |> factos_pog.dispatch(command, event_id: uuid.v4_string) process.send(messages, DispatchFinished(worker:, result:)) }) } @@ -1218,7 +1051,7 @@ fn start_shared_postgres() -> SharedPostgres { let host = environment_variable("FACTOS_POG_TEST_HOST", default: "127.0.0.1") let port = test_postgres_port() let database = - environment_variable("FACTOS_POG_TEST_DATABASE", default: "factos_pog") + environment_variable("FACTOS_POG_TEST_DATABASE", default: "postgres") let username = environment_variable("FACTOS_POG_TEST_USERNAME", default: "postgres") let password = @@ -1241,7 +1074,7 @@ fn environment_variable(name: String, default default_value: String) -> String { } fn test_postgres_port() -> Int { - let value = environment_variable("FACTOS_POG_TEST_PORT", default: "55432") + let value = environment_variable("FACTOS_POG_TEST_PORT", default: "5432") let assert Ok(port) = int.parse(value) port } @@ -1661,8 +1494,8 @@ fn insert_unknown_event(connection: pog.Connection) -> Nil { fn username_decision_context(username: String) -> factos.DecisionContext { factos.Matching([ - factos.item(types: [factos.event_type("UserRegistered")], tags: [ - factos.tag("username:" <> username), + factos.Item(types: [factos.EventType("UserRegistered")], tags: [ + factos.Tag("username:" <> username), ]), ]) } @@ -1675,18 +1508,15 @@ fn assert_user_recorded( let assert Ok(event_id) = uuid.from_string(recorded.id) assert uuid.version(event_id) == uuid.V4 assert recorded.position == position - assert recorded.event.descriptor.type_ == factos.event_type("UserRegistered") + assert recorded.event.descriptor.type_ == factos.EventType("UserRegistered") assert recorded.event.descriptor.version == 1 - assert recorded.event.descriptor.tags == [factos.tag("username:" <> username)] - assert factos.metadata_get( - recorded.event.descriptor.metadata, - factos.correlation_id, - ) - == Ok("event-" <> username) + assert recorded.event.descriptor.tags == [factos.Tag("username:" <> username)] + assert recorded.event.descriptor.metadata + == factos.metadata([#("correlation_id", "event-" <> username)]) assert recorded.event.payload == UserRegistered(username) } -fn decider() -> factos.Decider(Command, State, Event, DomainError) { +fn decider(_) -> factos.Decider(Command, State, Event, DomainError) { factos.decider(initial: Available, decide:, evolve:) } @@ -1727,50 +1557,41 @@ fn empty_query() -> factos.DecisionContext { fn username_conformance_query() -> factos.DecisionContext { factos.Matching([ - factos.item(types: [factos.event_type("UserRegistered")], tags: [ - factos.tag("username:renata"), - factos.tag("username:lucy"), + factos.Item(types: [factos.EventType("UserRegistered")], tags: [ + factos.Tag("username:renata"), + factos.Tag("username:lucy"), ]), - factos.item( + factos.Item( types: [ - factos.event_type("UnknownEventType"), - factos.event_type("UserRegistered"), + factos.EventType("UnknownEventType"), + factos.EventType("UserRegistered"), ], - tags: [factos.tag("username:lucy")], + tags: [factos.Tag("username:lucy")], ), ]) } fn encode(event: Event) -> factos.Event(json.Json) { - factos.new_event( - type_: factos.event_type("UserRegistered"), + factos.event( + type_: factos.EventType("UserRegistered"), version: 1, data: json.string(event.username), ) |> factos.with_tags(tags: [ - factos.tag("username:" <> event.username), + factos.Tag("username:" <> event.username), ]) |> factos.with_metadata( metadata: factos.metadata([ - #(factos.correlation_id, "event-" <> event.username), + #("correlation_id", "event-" <> event.username), ]), ) } -fn decode( - stored: factos.Recorded(String), -) -> Result(Event, factos.Recorded(String)) { - case - factos.event_type_to_string(stored.event.descriptor.type_), - stored.event.descriptor.version - { - "UserRegistered", 1 -> - json.parse( - stored.event.payload, - using: decode.string |> decode.map(UserRegistered), - ) - |> result.replace_error(stored) - _, _ -> Error(stored) +fn decode(type_: factos.EventType, version: Int) { + case type_, version { + factos.EventType("UserRegistered"), 1 -> + Ok(decode.string |> decode.map(UserRegistered)) + _, _ -> Error(Nil) } } @@ -1779,15 +1600,19 @@ fn dispatch_counter_context_many( decision_context: factos.DecisionContext, remaining: Int, ) -> Result(factos.Dispatch(CounterEvent), factos_pog.Error(Nil, Nil)) { - let result = - factos.new_dispatch( - connection:, - decision_context:, - decider: counter_decider(), + let model = + factos.model( + decider: fn(_) { counter_decider() }, encode: encode_counter_event, decode: decode_counter_event, ) - |> factos_pog.dispatch(Increment, event_id: uuid.v4_string) + let result = + factos_pog.configure(connection:, model:) + |> factos_pog.dispatch( + Increment, + decision_context:, + event_id: uuid.v4_string, + ) case remaining, result { 1, _ -> result _, Ok(_) -> @@ -1831,29 +1656,23 @@ fn counter_evolve(state: CounterState, event: CounterEvent) -> CounterState { fn encode_counter_event(event: CounterEvent) -> factos.Event(json.Json) { case event { Incremented(value) -> - factos.new_event( - type_: factos.event_type("Incremented"), + factos.event( + type_: factos.EventType("Incremented"), version: 1, data: json.int(value), ) - |> factos.with_tags(tags: [factos.tag("counter:load")]) + |> factos.with_tags(tags: [factos.Tag("counter:load")]) } } fn decode_counter_event( - stored: factos.Recorded(String), -) -> Result(CounterEvent, factos.Recorded(String)) { - case - factos.event_type_to_string(stored.event.descriptor.type_), - stored.event.descriptor.version - { - "Incremented", 1 -> - json.parse( - stored.event.payload, - using: decode.int |> decode.map(Incremented), - ) - |> result.replace_error(stored) - _, _ -> Error(stored) + type_: factos.EventType, + version: Int, +) -> Result(decode.Decoder(CounterEvent), Nil) { + case type_, version { + factos.EventType("Incremented"), 1 -> + Ok(decode.int |> decode.map(Incremented)) + _, _ -> Error(Nil) } } @@ -1868,7 +1687,7 @@ fn assert_counter_recorded( assert recorded.position == position assert recorded.event.descriptor.type_ == type_ assert recorded.event.descriptor.version == 1 - assert recorded.event.descriptor.tags == [factos.tag("counter:load")] + assert recorded.event.descriptor.tags == [factos.Tag("counter:load")] assert recorded.event.descriptor.metadata == factos.empty_metadata() assert recorded.event.payload == Incremented(value) } diff --git a/backends/factos_sqlight/CHANGELOG.md b/backends/factos_sqlight/CHANGELOG.md new file mode 100644 index 0000000..1007488 --- /dev/null +++ b/backends/factos_sqlight/CHANGELOG.md @@ -0,0 +1 @@ +# factos_sqlight changelog diff --git a/backends/factos_sqlight/README.md b/backends/factos_sqlight/README.md index bdb278c..e5c5e95 100644 --- a/backends/factos_sqlight/README.md +++ b/backends/factos_sqlight/README.md @@ -39,87 +39,52 @@ The schema contains one backend-owned table: There are no stream, per-stream revision, projection, subscription, checkpoint, or outbox tables. Applications own read models and durable delivery state. -## Define a codec +## Define the model and dispatch commands -SQLite stores opaque bytes. The application codec maps domain events to the -shared `factos.Event(BitArray)` storage contract and decodes raw rows back into -domain events. +The application supplies a JSON event codec: ```gleam -import factos -import factos/factos_sqlight -import gleam/bit_array -import gleam/result - -fn ticket_codec() -> factos.EventCodec(Event, BitArray) { - factos.codec(encode: encode_event, decode: decode_event) -} - -fn encode_event(event: Event) -> factos.Event(BitArray) { +fn encode_event(event: Event) -> factos.Event(json.Json) { let TicketSold(buyer:) = event - - factos.new_event( - type_: factos.event_type("TicketSold"), + factos.event( + type_: factos.EventType("TicketSold"), version: 1, - data: bit_array.from_string(buyer), + data: json.string(buyer), ) - |> factos.with_tags(tags: [factos.tag("event:gleamconf-2026")]) + |> factos.with_tags(tags: [factos.Tag("event:gleamconf-2026")]) } -fn decode_event( - stored: factos.Recorded(BitArray), -) -> Result(Event, factos.DecodeError) { - let descriptor = stored.descriptor - - case factos.event_type_name(descriptor.type_), descriptor.version { - "TicketSold", 1 -> { - use buyer <- result.try( - bit_array.to_string(stored.event) - |> result.replace_error(factos.InvalidData), - ) - Ok(TicketSold(buyer:)) - } - _, _ -> Error(factos.UnknownEvent) +fn decode_event(type_: factos.EventType, version: Int) { + case type_, version { + factos.EventType("TicketSold"), 1 -> + Ok(decode.string |> decode.map(TicketSold)) + _, _ -> Error(Nil) } } ``` -The SQLite row decoder reads the `data` column with -`gleam/dynamic/decode.bit_array`, so the application decoder receives the raw -payload as `stored.event` on a `factos.Recorded(BitArray)`. - -The descriptor read from SQLite remains authoritative on the resulting -`factos.Recorded` value. Tags are the selective-read contract: if a future -command must find an event by a payload value, expose that value as a stable tag -when encoding the event. - -## Dispatch commands - -Every builder requires an explicit decision context. Use: - -- `factos.NoContext` for commands that intentionally ignore prior facts; -- `factos.AllEvents` for global rules; -- `factos.Matching(items:)` for selective type-and-tag rules. +Build reusable configuration, then supply the decision context for each +dispatch: ```gleam -fn sale_context(event_id: String) -> factos.DecisionContext { - factos.Matching(items: [ - factos.item( - types: [factos.event_type("TicketSold")], - tags: [factos.tag("event:" <> event_id)], - ), - ]) -} +let configuration = + factos.default( + connection:, + decider: fn(_command) { ticket_decider() }, + encode: encode_event, + decode: decode_event, + ) let assert Ok(dispatch) = - factos.new_dispatch( - connection: connection, - decision_context: sale_context("gleamconf-2026"), - decider: ticket_decider(), - codec: ticket_codec(), - ) + configuration |> factos_sqlight.dispatch( BuyTicket(buyer: "renata"), + decision_context: factos.Matching(items: [ + factos.Item( + types: [factos.EventType("TicketSold")], + tags: [factos.Tag("event:gleamconf-2026")], + ), + ]), event_id: new_event_id, ) ``` @@ -151,39 +116,34 @@ strong callback side effects on the supplied transaction connection. ## Dispatch-bound subscriptions -A subscription filters only events accepted by the dispatch carrying it. It is -not a historical consumer and has no cursor, replay, checkpoint, or catch-up -state. - ```gleam let projection = - factos.new_subscription( - decision_context: factos.AllEvents, - consistency: factos.StrongConsistency, - handle: fn(transaction_connection, recorded) { - projection.insert(transaction_connection, recorded) - }, + factos.subscription(handle: fn(transaction_connection, recorded) { + projection.insert(transaction_connection, recorded) + }) + +let configuration = + factos.default( + connection:, + decider: fn(_command) { ticket_decider() }, + encode: encode_event, + decode: decode_event, ) + |> factos.with_subscriptions([projection]) -let assert Ok(dispatch) = - factos.new_dispatch( - connection: connection, - decision_context: sale_context("gleamconf-2026"), - decider: ticket_decider(), - codec: ticket_codec(), - ) - |> factos.with_subscriptions(subscriptions: [projection]) - |> factos_sqlight.dispatch( - BuyTicket(buyer: "renata"), - event_id: new_event_id, - ) +configuration +|> factos_sqlight.dispatch( + BuyTicket(buyer: "renata"), + decision_context: sale_context("gleamconf-2026"), + event_id: new_event_id, +) ``` -### Strong consistency +### Strong subscriptions -`factos.StrongConsistency` runs each matching callback once per accepted record, -in subscription order and then append order, before commit. All callbacks receive -the dispatch transaction connection. +Strong callbacks run once per accepted record, in subscription order and then +append order, before commit. All callbacks receive the dispatch transaction +connection. The first returned callback error becomes `factos.SubscriptionError(error:)`. SQLite rolls back the callback writes, every @@ -194,22 +154,21 @@ Use this mode to update SQLite projections or insert application-owned outbox rows atomically with accepted events. Do not perform external network or file IO inside the transaction. -### Fire and forget +### After commit -`factos.FireAndForget` starts one independent asynchronous process for each -matching subscription after the final commit succeeds. There is no supervisor -to install or name to configure. +`factos.new_after_commit_subscription` configures an independent asynchronous +process that starts after the final commit. Attach these callbacks with +`factos.with_after_commit_subscriptions`. There is no supervisor to install or +name to configure. -Each process handles that subscription's matching records in append order. -Returned callback errors are ignored and processing continues. A panic -terminates the process and drops its remaining records; the process is not -supervised or restarted. Separate subscriptions and dispatches may run -concurrently, with no ordering guarantee between their processes. +Each process handles that subscription's records in append order. Returned +callback errors are ignored and processing continues. A panic terminates the +process and drops its remaining records; the process is not supervised or +restarted. Separate subscriptions and dispatches may run concurrently. Failure to start or finish this best-effort work cannot change the already -committed dispatch result. This mode has no retry, replay, idempotency, -checkpoint, or dead-letter guarantee. For durable external delivery, insert an -application-owned outbox row from a strong callback and run a separate worker. +committed dispatch result. It has no retry, replay, idempotency, checkpoint, or +dead-letter guarantee. ## Errors diff --git a/backends/factos_sqlight/docs/how-it-works.md b/backends/factos_sqlight/docs/how-it-works.md index 4d0861a..08f7dae 100644 --- a/backends/factos_sqlight/docs/how-it-works.md +++ b/backends/factos_sqlight/docs/how-it-works.md @@ -76,11 +76,9 @@ decision. 4. run the pure `decide` function; 5. verify that no matching event appeared after the observed position; 6. call the supplied event-id function and insert each accepted event in order; -7. run matching `factos.StrongConsistency` callbacks in subscription and append - order; +7. run strong callbacks in subscription and append order; 8. commit; -9. start an independent process for each matching `factos.FireAndForget` - subscription; +9. start an independent process for each after-commit subscription; 10. return `factos.Dispatch(position:, events:)`. `BEGIN IMMEDIATE` acquires the writer lock before the read. Other writers cannot @@ -105,9 +103,8 @@ because an aborted attempt rolls those writes back before another attempt. ## Strong subscriptions -A `factos.StrongConsistency` subscription filters only records accepted by its -carrying dispatch. Matching callbacks receive the transaction connection and -execute before commit. +A strong subscription filters only records accepted by its carrying dispatch. +Callbacks receive the transaction connection and execute before commit. Callbacks run one subscription at a time and one record at a time. The first returned error becomes `factos.SubscriptionError(error:)`; no later callback @@ -121,11 +118,11 @@ This mode is appropriate for transactionally maintained SQLite projections and application-owned outbox rows. External IO does not belong inside the transaction. -## Fire-and-forget subscriptions +## After-commit subscriptions -A `factos.FireAndForget` subscription starts only after the final commit. Each -matching subscription gets one independent asynchronous process directly; there -is no supervisor name, supervision tree integration, or restart policy. +An after-commit subscription starts only after the final commit. Each +subscription gets one independent asynchronous process directly; there is no +supervisor name, supervision tree integration, or restart policy. The process handles that subscription's matching records in append order. Returned callback errors are ignored and processing continues. A panic diff --git a/backends/factos_sqlight/manifest.toml b/backends/factos_sqlight/manifest.toml index 4cfef1b..a24558a 100644 --- a/backends/factos_sqlight/manifest.toml +++ b/backends/factos_sqlight/manifest.toml @@ -9,7 +9,7 @@ packages = [ { name = "esqlite", version = "0.9.0", build_tools = ["rebar3"], requirements = [], otp_app = "esqlite", source = "hex", outer_checksum = "CCF72258A4EE152EC7AD92AA9A03552EB6CA1B06B65C93AD5B6E55C302E05855" }, { name = "exception", version = "2.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "6BDEA95248093599391C3B5DF1835C5C6A86C353C2F99CE539B450E3432FE117" }, - { name = "factos", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], source = "local", path = "../.." }, + { name = "factos", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], source = "local", path = "../.." }, { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, { 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" }, diff --git a/backends/factos_sqlight/src/factos/factos_sqlight.gleam b/backends/factos_sqlight/src/factos/factos_sqlight.gleam index 4d1debe..120c4a9 100644 --- a/backends/factos_sqlight/src/factos/factos_sqlight.gleam +++ b/backends/factos_sqlight/src/factos/factos_sqlight.gleam @@ -8,26 +8,55 @@ //// command context is read, so every accepted command observes and protects one //// stable event-log state through append, strong callbacks, and commit. -import exception import factos import gleam/dict import gleam/dynamic/decode -import gleam/erlang/process import gleam/json import gleam/list import gleam/result import gleam/string import sqlight -pub type Decoder(event) = - fn(factos.Recorded(String)) -> Result(event, factos.Recorded(String)) +pub type Configuration( + command, + state, + event, + delivery, + transaction, + domain_error, + subscription_error, +) { + Configuration( + model: factos.Model(command, state, event, domain_error), + connection: sqlight.Connection, + retry_attempts: Int, + subscriptions: List( + factos.Subscription(delivery, subscription_error, transaction), + ), + ) +} + +pub fn configure( + model model: factos.Model(command, state, event, domain_error), + connection connection: sqlight.Connection, +) -> Configuration( + command, + state, + event, + delivery, + transaction, + domain_error, + subscription_error, +) { + Configuration(model:, connection:, retry_attempts: 5, subscriptions: []) +} pub type Error(domain_error, subscription_error) = factos.Error( domain_error, subscription_error, sqlight.Error, - factos.Recorded(String), + json.DecodeError, ) type QuerySql { @@ -42,67 +71,41 @@ type PreparedEvent(event) { ) } -/// Execute a shared dispatch builder against SQLite. -/// -/// Dispatch uses `BEGIN IMMEDIATE` to read the decision context, decide, append, -/// and run strong subscriptions in one transaction. The shared builder starts -/// with five attempts for retryable SQLite lock conflicts. -/// -/// Decider and codec functions must be pure. Strong subscription callbacks can -/// run again if the transaction is retried after they return. +/// Execute one configured dispatch against SQLite. /// -/// After the final commit, every matching `factos.FireAndForget` subscription -/// starts an independent asynchronous process. Those processes are not -/// supervised and cannot change the committed dispatch result. +/// Dispatch uses `BEGIN IMMEDIATE` to read the request's decision context, +/// decide, append, and run subscriptions in one transaction. Codec, decider, +/// and subscription functions must remain deterministic across retries. pub fn dispatch( - builder: factos.DispatchBuilder( + configuration: Configuration( command, state, event, - json.Json, - String, + factos.Recorded(event), + sqlight.Connection, domain_error, subscription_error, - sqlight.Connection, - factos.Recorded(String), ), - command: command, + command command: command, + decision_context decision_context: factos.DecisionContext, event_id event_id: fn() -> String, ) -> Result(factos.Dispatch(event), Error(domain_error, subscription_error)) { - let factos.DispatchBuilder( - connection:, + let Configuration(model:, connection:, retry_attempts:, subscriptions:) = + configuration + let factos.Model(decider:, encode:, decode:) = model + let decider = decider(command) + + dispatch_context( + connection, decision_context:, decider:, encode:, decode:, + command:, + event_id:, retry_attempts:, subscriptions:, - ) = builder - - let result = - dispatch_context( - connection, - decision_context:, - decider:, - encode:, - decode:, - command:, - event_id:, - retry_attempts:, - subscriptions:, - ) - - case result { - Error(error) -> Error(error) - Ok(dispatch) -> { - enqueue_fire_and_forget_subscriptions( - connection, - subscriptions, - dispatch.events, - ) - Ok(dispatch) - } - } + ) } /// Create the fresh SQLite schema required by this backend. @@ -127,13 +130,11 @@ pub fn read( connection: sqlight.Connection, decision_context: factos.DecisionContext, decider: factos.Decider(command, state, event, domain_error), - decode: fn(factos.Recorded(String)) -> Result(event, factos.Recorded(String)), + decode: fn(factos.EventType, Int) -> Result(decode.Decoder(event), Nil), ) -> Result( factos.Context(event, state), Error(domain_error, subscription_error), ) { - let factos.Decider(initial:, evolve:, ..) = decider - use events <- result.try(read_matching_events( connection, decision_context, @@ -143,7 +144,7 @@ pub fn read( Ok(factos.Context( decision_context:, - state: factos.evolve_recorded(initial:, events:, evolve:), + state: factos.evolve_recorded(decider, events:), events:, position:, append_condition: factos.FailIfEventsMatch( @@ -163,7 +164,7 @@ pub fn read_after( decision_context: factos.DecisionContext, after: factos.SequencePosition, limit: Int, - decode: Decoder(event), + decode: fn(factos.EventType, Int) -> Result(decode.Decoder(event), Nil), ) -> Result( List(factos.Recorded(event)), Error(domain_error, subscription_error), @@ -179,9 +180,9 @@ pub fn read_after( order by position limit ?", on: connection, with: list.append(arguments, [ sqlight.int(limit), - ]), expecting: stored_row_decoder()) + ]), expecting: stored_row_decoder(decode)) |> result.map_error(factos.StoreError) - |> result.try(decode_stored_events(_, decode)) + |> result.try(result.all) } } } @@ -191,12 +192,16 @@ fn dispatch_context( decision_context decision_context: factos.DecisionContext, decider decider: factos.Decider(command, state, event, domain_error), encode encode: fn(event) -> factos.Event(json.Json), - decode decode: Decoder(event), + decode decode: fn(factos.EventType, Int) -> Result(decode.Decoder(event), Nil), command command: command, event_id event_id: fn() -> String, retry_attempts retry_attempts: Int, subscriptions subscriptions: List( - factos.Subscription(event, subscription_error, sqlight.Connection), + factos.Subscription( + factos.Recorded(event), + subscription_error, + sqlight.Connection, + ), ), ) -> Result(factos.Dispatch(event), Error(domain_error, subscription_error)) { use transaction_connection <- run_serializable_transaction( @@ -209,7 +214,7 @@ fn dispatch_context( decider, decode, )) - use #(context, events) <- result.try( + use events <- result.try( factos.decide_context(context, command, decider) |> result.map_error(factos.DomainError), ) @@ -306,9 +311,10 @@ fn retryable_transaction_error( factos.StoreError(sqlight.SqlightError(code:, ..)) -> retryable_sqlite_code(code) factos.DomainError(_) -> False - factos.SubscriptionError(error: _) -> False + factos.SubscriptionError(_) -> False factos.AppendConditionFailed(_) -> False factos.DecodeError(_) -> False + factos.InvalidSchema(_, _) -> False } } @@ -326,79 +332,37 @@ fn retryable_sqlite_code(code: sqlight.ErrorCode) -> Bool { fn run_strong_subscriptions( connection: sqlight.Connection, subscriptions: List( - factos.Subscription(event, subscription_error, sqlight.Connection), + factos.Subscription( + factos.Recorded(event), + subscription_error, + sqlight.Connection, + ), ), events: List(factos.Recorded(event)), -) -> Result(Nil, Error(domain_error, subscription_error)) { +) -> Result(sqlight.Connection, Error(domain_error, subscription_error)) { case subscriptions { - [] -> Ok(Nil) - [factos.Subscription(consistency:, handle:), ..remaining] -> - case consistency { - factos.FireAndForget -> - run_strong_subscriptions(connection, remaining, events) - factos.StrongConsistency -> { - use _ <- result.try( - run_strong_subscription_events(connection, handle, events) - |> result.map_error(factos.SubscriptionError), - ) - run_strong_subscriptions(connection, remaining, events) - } - } - } -} - -fn run_strong_subscription_events( - connection: sqlight.Connection, - handle: fn(sqlight.Connection, factos.Recorded(event)) -> - Result(Nil, subscription_error), - events: List(factos.Recorded(event)), -) -> Result(Nil, subscription_error) { - case events { - [] -> Ok(Nil) - [recorded, ..remaining] -> { - use Nil <- result.try(handle(connection, recorded)) - run_strong_subscription_events(connection, handle, remaining) - } - } -} - -fn enqueue_fire_and_forget_subscriptions( - connection: sqlight.Connection, - subscriptions: List( - factos.Subscription(event, subscription_error, sqlight.Connection), - ), - events: List(factos.Recorded(event)), -) -> Nil { - use subscription <- list.each(subscriptions) - case subscription.consistency { - factos.StrongConsistency -> Nil - factos.FireAndForget -> { - case events { - [] -> Nil - events -> { - let _ = { - use <- exception.rescue - use <- process.spawn() - run_fire_and_forget_events(connection, events, subscription.handle) - } - Nil - } - } + [] -> Ok(connection) + [factos.Subscription(apply:), ..remaining] -> { + use connection <- result.try( + run_strong_subscription_events(connection, apply, events) + |> result.map_error(factos.SubscriptionError), + ) + run_strong_subscriptions(connection, remaining, events) } } } -fn run_fire_and_forget_events( +fn run_strong_subscription_events( connection: sqlight.Connection, + apply: fn(sqlight.Connection, factos.Recorded(event)) -> + Result(sqlight.Connection, subscription_error), events: List(factos.Recorded(event)), - handle: fn(sqlight.Connection, factos.Recorded(event)) -> - Result(Nil, subscription_error), -) -> Nil { +) -> Result(sqlight.Connection, subscription_error) { case events { - [] -> Nil + [] -> Ok(connection) [recorded, ..remaining] -> { - let _ = handle(connection, recorded) - run_fire_and_forget_events(connection, remaining, handle) + use connection <- result.try(apply(connection, recorded)) + run_strong_subscription_events(connection, apply, remaining) } } } @@ -464,7 +428,7 @@ fn insert_event_batch( on: connection, with: [ sqlight.text(event.id), - sqlight.text(factos.event_type_to_string(type_)), + sqlight.text(event_type_name(type_)), sqlight.int(version), sqlight.text(tags_to_text(tags)), sqlight.text(metadata_to_text(metadata)), @@ -513,7 +477,7 @@ fn has_matching_events_after_condition( fn read_matching_events( connection: sqlight.Connection, decision_context: factos.DecisionContext, - decode: Decoder(event), + decode: fn(factos.EventType, Int) -> Result(decode.Decoder(event), Nil), ) -> Result( List(factos.Recorded(event)), Error(domain_error, subscription_error), @@ -521,48 +485,57 @@ fn read_matching_events( let QuerySql(where_sql, arguments) = query_to_sql(decision_context) sqlight.query("select position, id, type, version, tags, metadata, data from factos_events " <> where_sql <> " - order by position", on: connection, with: arguments, expecting: stored_row_decoder()) + order by position", on: connection, with: arguments, expecting: stored_row_decoder( + decode, + )) |> result.map_error(factos.StoreError) - |> result.try(decode_stored_events(_, decode)) + |> result.try(result.all) } -fn stored_row_decoder() -> decode.Decoder(factos.Recorded(String)) { +fn stored_row_decoder( + decode: fn(factos.EventType, Int) -> Result(decode.Decoder(event), Nil), +) -> decode.Decoder( + Result(factos.Recorded(event), Error(domain_error, subscription_error)), +) { use position <- decode.field(0, decode.int) use id <- decode.field(1, decode.string) - use type_ <- decode.field(2, decode.string |> decode.map(factos.event_type)) + use type_ <- decode.field(2, decode.string |> decode.map(factos.EventType)) use version <- decode.field(3, decode.int) use tags <- decode.field(4, tags_column_decoder()) use metadata <- decode.field(5, metadata_column_decoder()) - use payload <- decode.field(6, decode.string) - decode.success(factos.Recorded( - id:, - position: factos.SequencePosition(position), - event: factos.Event( - descriptor: factos.EventDescriptor(type_:, version:, tags:, metadata:), - payload:, - ), - )) -} -fn decode_stored_events( - rows: List(factos.Recorded(String)), - decode: Decoder(event), -) -> Result( - List(factos.Recorded(event)), - Error(domain_error, subscription_error), -) { - list.try_map(rows, fn(stored) { - case decode(stored) { - Ok(payload) -> - Ok( - factos.Recorded( - ..stored, - event: factos.Event(..stored.event, payload:), - ), - ) - Error(stored) -> Error(factos.DecodeError(stored)) + use payload <- decode.field(6, { + use payload <- decode.then(decode.string) + let decoder = decode(type_, version) + case decoder { + Ok(decoder) -> + case json.parse(payload, decoder) { + Ok(payload) -> decode.success(Ok(payload)) + Error(error) -> decode.success(Error(factos.DecodeError(error))) + } + Error(Nil) -> decode.success(Error(factos.InvalidSchema(type_, version))) } }) + + case payload { + Ok(payload) -> + decode.success( + Ok(factos.Recorded( + id:, + position: factos.SequencePosition(position), + event: factos.Event( + descriptor: factos.EventDescriptor( + type_:, + version:, + tags:, + metadata:, + ), + payload:, + ), + )), + ) + Error(error) -> decode.success(Error(error)) + } } fn metadata_column_decoder() -> decode.Decoder(factos.Metadata) { @@ -691,7 +664,7 @@ fn types_to_sql(types: List(factos.EventType)) -> QuerySql { QuerySql( sql: "type in (" <> placeholders(list.length(types)) <> ")", arguments: list.map(types, fn(type_) { - sqlight.text(factos.event_type_to_string(type_)) + sqlight.text(event_type_name(type_)) }), ) } @@ -705,7 +678,7 @@ fn tags_to_sql(tags: List(factos.Tag)) -> QuerySql { QuerySql( sql: "(" <> string.join(clauses, with: " and ") <> ")", arguments: list.map(tags, fn(tag) { - sqlight.text("\n" <> factos.tag_value(tag) <> "\n") + sqlight.text("\n" <> tag_text(tag) <> "\n") }), ) } @@ -731,9 +704,7 @@ fn tags_to_text(tags: List(factos.Tag)) -> String { case tags { [] -> "" [_, ..] -> - "\n" - <> { tags |> list.map(factos.tag_value) |> string.join(with: "\n") } - <> "\n" + "\n" <> { tags |> list.map(tag_text) |> string.join(with: "\n") } <> "\n" } } @@ -744,7 +715,7 @@ fn tags_from_text(tags: String) -> List(factos.Tag) { tags |> string.split(on: "\n") |> list.filter(fn(tag) { !string.is_empty(tag) }) - |> list.map(factos.tag) + |> list.map(factos.Tag) } } @@ -754,7 +725,7 @@ fn tags_column_decoder() -> decode.Decoder(List(factos.Tag)) { case json.parse( tags, - using: decode.string |> decode.map(factos.tag) |> decode.list, + using: decode.string |> decode.map(factos.Tag) |> decode.list, ) { Ok(tags) -> decode.success(tags) @@ -767,10 +738,17 @@ fn tags_column_decoder() -> decode.Decoder(List(factos.Tag)) { } fn metadata_to_text(metadata: factos.Metadata) -> String { - metadata - |> factos.metadata_entries - |> list.map(fn(entry) { entry.0 <> "=" <> entry.1 }) - |> string.join(with: "\n") + factos.metadata_to_json(metadata) |> json.to_string +} + +fn event_type_name(type_: factos.EventType) -> String { + let factos.EventType(name) = type_ + name +} + +fn tag_text(tag: factos.Tag) -> String { + let factos.Tag(value) = tag + value } fn metadata_from_text(metadata: String) -> factos.Metadata { diff --git a/backends/factos_sqlight/test/factos_sqlight_test.gleam b/backends/factos_sqlight/test/factos_sqlight_test.gleam index c5af54e..734335e 100644 --- a/backends/factos_sqlight/test/factos_sqlight_test.gleam +++ b/backends/factos_sqlight/test/factos_sqlight_test.gleam @@ -34,15 +34,6 @@ type DomainError { AlreadyTaken(username: String) } -type FireMessage { - FireStarted( - pid: process.Pid, - event: factos.Recorded(Event), - committed_events: Int, - release: process.Subject(Nil), - ) -} - type CounterCommand { Increment } @@ -75,7 +66,7 @@ pub fn dispatch_uses_decision_context_and_application_event_ids_test() -> Nil { assert renata_dispatch.position == renata.position assert renata.id == "event-renata" assert renata.event.payload == UserRegistered(username: "renata") - assert renata.event.descriptor.tags == [factos.tag("username:renata")] + assert renata.event.descriptor.tags == [factos.Tag("username:renata")] let duplicate = dispatch_user(connection, "renata", event_id: "unused") assert duplicate @@ -158,18 +149,19 @@ pub fn read_after_filters_orders_and_rejects_unknown_events_test() -> Nil { ) let assert Ok(_) = - factos.new_dispatch( - connection:, - decision_context: factos.NoContext, - decider: accepting_decider(), + factos.model( + decider: fn(_) { accepting_decider() }, encode: hidden_encode_event, decode: decode_event, ) - |> factos_sqlight.dispatch(RegisterUser(username: "hidden"), event_id: fn() { - "hidden" - }) + |> factos_sqlight.configure(connection:) + |> factos_sqlight.dispatch( + RegisterUser(username: "hidden"), + decision_context: factos.NoContext, + event_id: fn() { "hidden" }, + ) - let assert Error(factos.DecodeError(stored)) = + let assert Error(factos.InvalidSchema(_, _)) = factos_sqlight.read_after( connection, factos.AllEvents, @@ -177,8 +169,6 @@ pub fn read_after_filters_orders_and_rejects_unknown_events_test() -> Nil { 10, decode_event, ) - assert stored.id == "hidden" - assert stored.event.descriptor.type_ == factos.event_type("HiddenEvent") Nil } @@ -187,30 +177,34 @@ pub fn empty_dispatch_has_no_position_and_skips_subscriptions_test() -> Nil { execute_migration_file(connection) let invocations = process.new_subject() let event_id_invocations = process.new_subject() - let subscriptions = [ - factos.new_subscription( - consistency: factos.StrongConsistency, - handle: fn(_connection, _recorded) { - process.send(invocations, "strong") - Ok(Nil) - }, - ), - ] + let subscription = + factos.subscription(apply: fn(connection, _recorded) { + process.send(invocations, "strong") + Ok(connection) + }) + let configuration = + factos_sqlight.Configuration( + ..factos_sqlight.configure( + factos.model( + decider: fn(_) { empty_decider() }, + encode: encode_event, + decode: decode_event, + ), + connection:, + ), + subscriptions: [subscription], + ) let assert Ok(dispatch) = - factos.new_dispatch( - connection:, + factos_sqlight.dispatch( + configuration, + DoNothing, decision_context: factos.NoContext, - decider: empty_decider(), - encode: encode_event, - decode: decode_event, + event_id: fn() { + process.send(event_id_invocations, Nil) + "unused" + }, ) - |> factos.with_subscriptions(subscriptions:) - |> factos_sqlight.dispatch(DoNothing, event_id: fn() { - process.send(event_id_invocations, Nil) - "unused" - }) - assert dispatch == factos.Dispatch(position: factos.NoPosition, events: []) let assert Error(Nil) = process.receive(invocations, within: 100) let assert Error(Nil) = process.receive(event_id_invocations, within: 100) @@ -222,10 +216,10 @@ pub fn strong_subscription_commits_with_dispatch_test() -> Nil { execute_migration_file(connection) create_projection_table(connection) let subscription = - factos.new_subscription( - consistency: factos.StrongConsistency, - handle: insert_projection, - ) + factos.subscription(apply: fn(connection, recorded) { + use Nil <- result.try(insert_projection(connection, recorded)) + Ok(connection) + }) let assert Ok(dispatch) = dispatch_user_with_subscriptions( @@ -244,142 +238,59 @@ pub fn strong_subscription_failure_rolls_back_everything_test() -> Nil { use connection <- sqlight.with_connection(":memory:") execute_migration_file(connection) create_projection_table(connection) - let fire_deliveries = process.new_subject() let insert = - factos.new_subscription( - consistency: factos.StrongConsistency, - handle: insert_projection, - ) + factos.subscription(apply: fn(connection, recorded) { + use Nil <- result.try(insert_projection(connection, recorded)) + Ok(connection) + }) let fail_after_observing_insert = - factos.new_subscription( - consistency: factos.StrongConsistency, - handle: fn(transaction_connection, recorded) { + factos.subscription( + apply: fn( + transaction_connection: sqlight.Connection, + recorded: factos.Recorded(Event), + ) { case projection_rows(transaction_connection) { [#(id, _)] if id == recorded.id -> Error("expected strong failure") _ -> Error("earlier strong callback was not visible") } }, ) - let fire = - factos.new_subscription( - consistency: factos.FireAndForget, - handle: fn(_connection, _recorded) { - process.send(fire_deliveries, Nil) - Ok(Nil) - }, - ) let result = dispatch_user_with_subscriptions( connection, "renata", event_id: "rolled-back", - subscriptions: [insert, fail_after_observing_insert, fire], + subscriptions: [insert, fail_after_observing_insert], ) let assert Error(error) = result - assert error == factos.SubscriptionError(error: "expected strong failure") + assert error == factos.SubscriptionError("expected strong failure") assert count_events(connection) == 0 assert projection_rows(connection) == [] - let assert Error(Nil) = process.receive(fire_deliveries, within: 100) Nil } -pub fn fire_and_forget_runs_after_commit_without_blocking_test() -> Nil { - use connection <- sqlight.with_connection(":memory:") - execute_migration_file(connection) - let deliveries = process.new_subject() - let subscription = - factos.new_subscription( - consistency: factos.FireAndForget, - handle: fn(callback_connection, recorded) { - let release = process.new_subject() - process.send( - deliveries, - FireStarted( - pid: process.self(), - event: recorded, - committed_events: count_events(callback_connection), - release:, - ), - ) - case process.receive(release, within: 5000) { - Ok(Nil) -> Error("ignored fire error") - Error(Nil) -> Error("release timed out") - } - }, - ) - - let assert Ok(dispatch) = - dispatch_user_with_subscriptions( - connection, - "renata", - event_id: "fire-event", - subscriptions: [subscription], - ) - let FireStarted(pid:, event:, committed_events:, release:) = - receive_fire_message(deliveries) - assert dispatch.events == [event] - assert committed_events == 1 - assert process.is_alive(pid) - let monitor = process.monitor(pid) - process.send(release, Nil) - wait_for_monitor(monitor) - assert count_events(connection) == 1 -} - -pub fn fire_and_forget_continues_after_callback_error_in_append_order_test() -> Nil { +pub fn dispatch_builder_with_one_retry_attempt_persists_events_test() -> Nil { use connection <- sqlight.with_connection(":memory:") execute_migration_file(connection) - let deliveries = process.new_subject() - let ids = process.new_subject() - process.send(ids, "pair-first") - process.send(ids, "pair-second") - let subscription = - factos.new_subscription( - consistency: factos.FireAndForget, - handle: fn(_connection, recorded) { - let UserRegistered(username:) = recorded.event.payload - process.send(deliveries, username) - Error("ignored") - }, - ) let assert Ok(dispatch) = - factos.new_dispatch( - connection:, - decision_context: factos.NoContext, - decider: accepting_decider(), - encode: encode_event, - decode: decode_event, + factos_sqlight.Configuration( + ..factos_sqlight.configure( + factos.model( + decider: fn(_) { decider() }, + encode: encode_event, + decode: decode_event, + ), + connection:, + ), + retry_attempts: 1, ) - |> factos.with_subscriptions(subscriptions: [subscription]) |> factos_sqlight.dispatch( - RegisterPair(first: "renata", second: "maria"), - event_id: fn() { receive_event_id(ids) }, - ) - assert list.map(dispatch.events, fn(recorded) { recorded.id }) - == ["pair-first", "pair-second"] - let assert Ok("renata") = process.receive(deliveries, within: 5000) - let assert Ok("maria") = process.receive(deliveries, within: 5000) - Nil -} - -pub fn dispatch_builder_with_one_retry_attempt_persists_events_test() -> Nil { - use connection <- sqlight.with_connection(":memory:") - execute_migration_file(connection) - - let assert Ok(dispatch) = - factos.new_dispatch( - connection:, + RegisterUser(username: "renata"), decision_context: username_context("renata"), - decider: decider(), - encode: encode_event, - decode: decode_event, + event_id: fn() { "single-retry" }, ) - |> factos.with_retry_attempts(attempts: 1) - |> factos_sqlight.dispatch(RegisterUser(username: "renata"), event_id: fn() { - "single-retry" - }) let assert factos.SequencePosition(_) = dispatch.position let assert [recorded] = dispatch.events @@ -437,8 +348,8 @@ pub fn dispatch_builder_with_query_handles_many_events_test() -> Nil { let query = factos.Matching(items: [ - factos.item(types: [factos.event_type("Incremented")], tags: [ - factos.tag("counter:load"), + factos.Item(types: [factos.EventType("Incremented")], tags: [ + factos.Tag("counter:load"), ]), ]) @@ -449,7 +360,7 @@ pub fn dispatch_builder_with_query_handles_many_events_test() -> Nil { recorded, position: dispatch.position, value: 25, - type_: factos.event_type("Incremented"), + type_: factos.EventType("Incremented"), ) let assert Ok(context) = @@ -470,38 +381,41 @@ pub fn context_semantics_conformance_test() -> Nil { let decision_context = empty_query() let assert Ok(renata_dispatch) = - factos.new_dispatch( - connection:, - decision_context:, - decider: accepting_decider(), + factos.model( + decider: fn(_) { accepting_decider() }, encode: encode_event, decode: decode_event, ) - |> factos_sqlight.dispatch(RegisterUser(username: "renata"), event_id: fn() { - "conformance-renata" - }) - let assert Ok(lucy_dispatch) = - factos.new_dispatch( - connection:, + |> factos_sqlight.configure(connection:) + |> factos_sqlight.dispatch( + RegisterUser(username: "renata"), decision_context:, - decider: accepting_decider(), + event_id: fn() { "conformance-renata" }, + ) + let assert Ok(lucy_dispatch) = + factos.model( + decider: fn(_) { accepting_decider() }, encode: encode_event, decode: decode_event, ) - |> factos_sqlight.dispatch(RegisterUser(username: "lucy"), event_id: fn() { - "conformance-lucy" - }) + |> factos_sqlight.configure(connection:) + |> factos_sqlight.dispatch( + RegisterUser(username: "lucy"), + decision_context:, + event_id: fn() { "conformance-lucy" }, + ) let assert Ok(marc_dispatch) = - factos.new_dispatch( - connection:, - decision_context: factos.AllEvents, - decider: accepting_decider(), + factos.model( + decider: fn(_) { accepting_decider() }, encode: encode_event, decode: decode_event, ) - |> factos_sqlight.dispatch(RegisterUser(username: "marc"), event_id: fn() { - "conformance-marc" - }) + |> factos_sqlight.configure(connection:) + |> factos_sqlight.dispatch( + RegisterUser(username: "marc"), + decision_context: factos.AllEvents, + event_id: fn() { "conformance-marc" }, + ) let renata_position = renata_dispatch.position let lucy_position = lucy_dispatch.position let marc_position = marc_dispatch.position @@ -550,16 +464,17 @@ pub fn no_context_dispatch_skips_existing_events_test() -> Nil { let assert Ok(first_dispatch) = dispatch_user(connection, "renata", event_id: "no-context-first") let assert Ok(second_dispatch) = - factos.new_dispatch( - connection:, - decision_context: factos.NoContext, - decider: decider(), + factos.model( + decider: fn(_) { decider() }, encode: encode_event, decode: decode_event, ) - |> factos_sqlight.dispatch(RegisterUser(username: "renata"), event_id: fn() { - "no-context-second" - }) + |> factos_sqlight.configure(connection:) + |> factos_sqlight.dispatch( + RegisterUser(username: "renata"), + decision_context: factos.NoContext, + event_id: fn() { "no-context-second" }, + ) let assert [first] = first_dispatch.events let assert [second] = second_dispatch.events @@ -624,44 +539,35 @@ fn empty_decider() -> factos.Decider(Command, State, Event, DomainError) { fn encode_event(event: Event) -> factos.Event(json.Json) { let UserRegistered(username:) = event - factos.new_event( - type_: factos.event_type("UserRegistered"), + factos.event( + type_: factos.EventType("UserRegistered"), version: 1, data: json.string(username), ) - |> factos.with_tags(tags: [factos.tag("username:" <> username)]) + |> factos.with_tags(tags: [factos.Tag("username:" <> username)]) } fn hidden_encode_event(event: Event) -> factos.Event(json.Json) { let UserRegistered(username:) = event - factos.new_event( - type_: factos.event_type("HiddenEvent"), + factos.event( + type_: factos.EventType("HiddenEvent"), version: 1, data: json.string(username), ) } -fn decode_event( - stored: factos.Recorded(String), -) -> Result(Event, factos.Recorded(String)) { - case - factos.event_type_to_string(stored.event.descriptor.type_), - stored.event.descriptor.version - { - "UserRegistered", 1 -> - json.parse( - stored.event.payload, - using: decode.string |> decode.map(UserRegistered), - ) - |> result.replace_error(stored) - _, _ -> Error(stored) +fn decode_event(type_, version) { + case type_, version { + factos.EventType("UserRegistered"), 1 -> + Ok(decode.string |> decode.map(UserRegistered)) + _, _ -> Error(Nil) } } fn username_context(username: String) -> factos.DecisionContext { factos.Matching(items: [ - factos.item(types: [factos.event_type("UserRegistered")], tags: [ - factos.tag("username:" <> username), + factos.Item(types: [factos.EventType("UserRegistered")], tags: [ + factos.Tag("username:" <> username), ]), ]) } @@ -671,16 +577,17 @@ fn dispatch_user( username: String, event_id event_id: String, ) -> Result(factos.Dispatch(Event), factos_sqlight.Error(DomainError, Nil)) { - factos.new_dispatch( - connection:, - decision_context: username_context(username), - decider: decider(), + factos.model( + decider: fn(_) { decider() }, encode: encode_event, decode: decode_event, ) - |> factos_sqlight.dispatch(RegisterUser(username:), event_id: fn() { - event_id - }) + |> factos_sqlight.configure(connection:) + |> factos_sqlight.dispatch( + RegisterUser(username:), + decision_context: username_context(username), + event_id: fn() { event_id }, + ) } fn dispatch_user_with_subscriptions( @@ -688,20 +595,25 @@ fn dispatch_user_with_subscriptions( username: String, event_id event_id: String, subscriptions subscriptions: List( - factos.Subscription(Event, String, sqlight.Connection), + factos.Subscription(factos.Recorded(Event), String, sqlight.Connection), ), ) -> Result(factos.Dispatch(Event), factos_sqlight.Error(DomainError, String)) { - factos.new_dispatch( - connection:, + factos_sqlight.Configuration( + ..factos_sqlight.configure( + factos.model( + decider: fn(_) { decider() }, + encode: encode_event, + decode: decode_event, + ), + connection:, + ), + subscriptions:, + ) + |> factos_sqlight.dispatch( + RegisterUser(username:), decision_context: username_context(username), - decider: decider(), - encode: encode_event, - decode: decode_event, + event_id: fn() { event_id }, ) - |> factos.with_subscriptions(subscriptions:) - |> factos_sqlight.dispatch(RegisterUser(username:), event_id: fn() { - event_id - }) } fn insert_projection( @@ -755,26 +667,6 @@ fn count_events(connection: sqlight.Connection) -> Int { count } -fn receive_event_id(ids: process.Subject(String)) -> String { - let assert Ok(id) = process.receive(ids, within: 1000) - id -} - -fn receive_fire_message( - deliveries: process.Subject(FireMessage), -) -> FireMessage { - let assert Ok(message) = process.receive(deliveries, within: 5000) - message -} - -fn wait_for_monitor(monitor: process.Monitor) -> Nil { - let assert Ok(Nil) = - process.new_selector() - |> process.select_specific_monitor(monitor, fn(_) { Nil }) - |> process.selector_receive(5000) - process.demonitor_process(monitor) -} - fn assert_streamless_schema(connection: sqlight.Connection) -> Nil { let assert Ok(columns) = sqlight.query( @@ -849,16 +741,16 @@ fn empty_query() -> factos.DecisionContext { fn username_conformance_query() -> factos.DecisionContext { factos.Matching(items: [ - factos.item(types: [factos.event_type("UserRegistered")], tags: [ - factos.tag("username:renata"), - factos.tag("username:lucy"), + factos.Item(types: [factos.EventType("UserRegistered")], tags: [ + factos.Tag("username:renata"), + factos.Tag("username:lucy"), ]), - factos.item( + factos.Item( types: [ - factos.event_type("UnknownEventType"), - factos.event_type("UserRegistered"), + factos.EventType("UnknownEventType"), + factos.EventType("UserRegistered"), ], - tags: [factos.tag("username:lucy")], + tags: [factos.Tag("username:lucy")], ), ]) } @@ -869,9 +761,9 @@ fn assert_user_recorded( username username: String, ) -> Nil { assert recorded.position == position - assert recorded.event.descriptor.type_ == factos.event_type("UserRegistered") + assert recorded.event.descriptor.type_ == factos.EventType("UserRegistered") assert recorded.event.descriptor.version == 1 - assert recorded.event.descriptor.tags == [factos.tag("username:" <> username)] + assert recorded.event.descriptor.tags == [factos.Tag("username:" <> username)] assert recorded.event.descriptor.metadata == factos.empty_metadata() assert recorded.event.payload == UserRegistered(username:) } @@ -917,14 +809,13 @@ fn dispatch_counter_context_many( remaining: Int, ) -> Result(factos.Dispatch(CounterEvent), factos_sqlight.Error(Nil, Nil)) { let result = - factos.new_dispatch( - connection:, - decision_context:, - decider: counter_decider(), + factos.model( + decider: fn(_) { counter_decider() }, encode: encode_counter_event, decode: decode_counter_event, ) - |> factos_sqlight.dispatch(Increment, event_id: fn() { + |> factos_sqlight.configure(connection:) + |> factos_sqlight.dispatch(Increment, decision_context:, event_id: fn() { "counter-" <> int.to_string(remaining) }) case remaining, result { @@ -968,29 +859,20 @@ fn counter_evolve(state: CounterState, event: CounterEvent) -> CounterState { fn encode_counter_event(event: CounterEvent) -> factos.Event(json.Json) { case event { Incremented(value) -> - factos.new_event( - type_: factos.event_type("Incremented"), + factos.event( + type_: factos.EventType("Incremented"), version: 1, data: json.int(value), ) - |> factos.with_tags(tags: [factos.tag("counter:load")]) + |> factos.with_tags(tags: [factos.Tag("counter:load")]) } } -fn decode_counter_event( - stored: factos.Recorded(String), -) -> Result(CounterEvent, factos.Recorded(String)) { - case - factos.event_type_to_string(stored.event.descriptor.type_), - stored.event.descriptor.version - { - "Incremented", 1 -> - json.parse( - stored.event.payload, - using: decode.int |> decode.map(Incremented), - ) - |> result.replace_error(stored) - _, _ -> Error(stored) +fn decode_counter_event(type_, version) { + case type_, version { + factos.EventType("Incremented"), 1 -> + decode.int |> decode.map(Incremented) |> Ok + _, _ -> Error(Nil) } } @@ -1003,7 +885,7 @@ fn assert_counter_recorded( assert recorded.position == position assert recorded.event.descriptor.type_ == type_ assert recorded.event.descriptor.version == 1 - assert recorded.event.descriptor.tags == [factos.tag("counter:load")] + assert recorded.event.descriptor.tags == [factos.Tag("counter:load")] assert recorded.event.descriptor.metadata == factos.empty_metadata() assert recorded.event.payload == Incremented(value) } diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..7bd6d40 --- /dev/null +++ b/compose.yml @@ -0,0 +1,20 @@ +services: + postgres: + image: postgres:18 + environment: + POSTGRES_DB: postgres + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql + - ./dev/postgres/init-benchmark-database.sql:/docker-entrypoint-initdb.d/10-benchmark-database.sql:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"] + interval: 1s + timeout: 5s + retries: 20 + +volumes: + postgres_data: diff --git a/dev/postgres/init-benchmark-database.sql b/dev/postgres/init-benchmark-database.sql new file mode 100644 index 0000000..5cba581 --- /dev/null +++ b/dev/postgres/init-benchmark-database.sql @@ -0,0 +1,5 @@ +select format('create database %I', 'performance') +where not exists ( + select from pg_database where datname = 'performance' +) +\gexec diff --git a/docs/core-model.md b/docs/core-model.md index 1c199ea..31dd1b0 100644 --- a/docs/core-model.md +++ b/docs/core-model.md @@ -1,296 +1,91 @@ # The Factos Core Model -The `factos` package is the store-independent part of the library. It gives -applications a standard shape for command decisions over an event log, and it -gives backends shared types for reads, append conditions, and committed records. +`factos` is the store-independent part of Factos. Applications own their domain +language and codecs; backends own persistence and transaction guarantees. -It does not persist anything by itself. It does not maintain materialized views. -It does not execute effects. Those behaviours are provided by backend packages -and application code. +## Model -The core model has one job: keep the decision, projection, and reaction logic -explicit and pure. - -## Facts, not objects - -Factos starts from accepted facts. An application defines its own event type: - -```gleam -pub type Event { - TicketSold(buyer: String) -} -``` - -A fact is authoritative once accepted. Current state is derived by folding facts, -not by mutating a stored object in place. - -Factos does not require a base event interface. In Gleam the domain event type is -a custom type owned by the application. - -## Deciders - -A `Decider(command, state, event, domain_error)` is the command-side domain -component: - -```gleam -factos.decider( - initial: initial_state, - decide: decide, - evolve: evolve, -) -``` - -It has three parts: - -1. `initial`: the state before any relevant facts are folded; -2. `evolve`: how an accepted fact changes decision state; -3. `decide`: how a command is accepted or rejected from that state. - -The state is not necessarily a stored read model. It is the temporary state -needed for one decision. - -```gleam -fn decide(state: State, command: Command) -> Result(List(Event), DomainError) { - let TicketWindow(capacity:, sold:) = state - case command { - BuyTicket(buyer:) -> - case sold < capacity { - True -> Ok([TicketSold(buyer:)]) - False -> Error(SoldOut(capacity:)) - } - } -} -``` - -A decider's pure functions can be tested directly: - -```gleam -let factos.Decider(initial:, decide:, evolve:) = ticket_decider() -let state = - list.fold([TicketSold(buyer: "renata")], initial, evolve) - -decide(state, BuyTicket(buyer: "lucy")) -``` - -## Decision contexts - -A `DecisionContext` describes the facts relevant to one command. A backend uses -it both to read history and to protect the resulting append: - -```gleam -fn sale_context() -> factos.DecisionContext { - factos.Matching(items: [ - factos.item( - types: [factos.event_type("TicketSold")], - tags: [factos.tag("event:gleamconf-2026")], - ), - ]) -} -``` - -The three variants make the dependency explicit: - -- `NoContext` reads no history and represents an unconditional append; -- `AllEvents` reads and protects the complete event log; -- `Matching(items:)` selects events through type-and-tag matching. - -Within `Matching`: - -- items are OR-combined; -- event types inside one item are OR-combined; -- tags inside one item are AND-combined; -- empty types match any event type; -- empty tags add no tag constraint; -- an empty item list matches no events. - -Use `NoContext` rather than `Matching(items: [])` when ignoring history is an -intentional command-design choice. `EventType` and `Tag` are opaque wrappers so -applications are deliberate about which payload information is visible to -stores. - -## Contexts - -A backend read returns a `Context(event, state)`: +A model combines a command-selected decider with its event codec: ```gleam -factos.Context( - decision_context: sale_context(), - state: folded_state, - events: recorded_events, - position: observed_position, - append_condition: append_condition, -) -``` - -The context contains the matching facts used to make the decision, their folded -state, the highest observed global position, and the condition needed to keep -that decision valid until append time. - -## Append conditions - -The shared condition is: - -```gleam -factos.FailIfEventsMatch( - decision_context: sale_context(), - after: observed_position, -) -``` - -It means the backend must not append the newly decided facts if another -matching fact was accepted after the observed position. This is the -context-first consistency boundary: the boundary is the facts needed by the -rule, not a fixed aggregate object. - -`FailIfEventsMatch(decision_context: NoContext, ...)` is unconditional because -no event can match `NoContext`. Backends may still reject an append for their -own storage errors. - -## Domain simulations - -`factos/simulate` is an immutable, in-memory executable reference for the core -decision-context, append-condition, and decider semantics: - -```gleam -import factos/simulate - -fn describe_event(event: Event) -> factos.EventDescriptor { - case event { - TicketSold(buyer: _) -> - factos.EventDescriptor( - type_: factos.event_type("TicketSold"), - version: 1, - tags: [factos.tag("event:gleamconf-2026")], - metadata: factos.empty_metadata(), +let model = + factos.model( + decider: fn(_command) { + factos.decider( + initial: TicketWindow(capacity: 100, sold: 0), + decide: decide, + evolve: evolve, ) - } -} - -let store = - simulate.new(describe_event) - |> simulate.given(events: [TicketSold(buyer: "renata")]) - -let assert Ok(simulate.Commit(store:, events: committed)) = - simulate.dispatch( - store, - decision_context: sale_context(), - decider: ticket_decider(), - command: BuyTicket(buyer: "lucy"), + }, + encode: encode_event, + decode: decode_event, ) ``` -Rebinding `store` from `Commit` carries accepted facts into later commands. The -descriptor should adapt the same application-owned type, version, tags, and -metadata mapping used by production encoders. - -Use the explicit read and append operations to model a deterministic -interleaving: - -```gleam -let context = - simulate.read_context( - store, - decision_context: sale_context(), - decider: ticket_decider(), - ) - -// This matching fact arrives after the context was read. -let store = - simulate.given(store, events: [TicketSold(buyer: "marc")]) - -let result = - simulate.append( - store, - events: [TicketSold(buyer: "lucy")], - condition: context.append_condition, - ) - -let assert Error(simulate.AppendConditionFailed(condition: _)) = result -``` +The decider is pure: -An interleaved event outside `sale_context()` leaves the same condition valid; -the matching `TicketSold` above makes the context stale and rejects the whole -batch. +- `initial` is the empty decision state; +- `evolve` folds accepted events into that state; +- `decide` returns new events or a domain error. -The simulator is not a backend interface. Keep encoder/decoder fidelity, storage -predicate selection, transaction rollback, retries, outbox behavior, and real -concurrency in backend integration tests. Test `decide` and `evolve` directly -when no recorded store transition is needed. +The encoder returns `Event(json.Json)`. The decoder selects a dynamic decoder +from an event type and version. +## Decision contexts -## Recorded events +Every dispatch names the facts that can change its answer: -Backends decode stored data into `Recorded(event)` values: +- `NoContext` reads no history and permits an unconditional append; +- `AllEvents` reads and protects the complete log; +- `Matching(items:)` selects events by type and tags. ```gleam -factos.Recorded( - id: id, - position: position, - event: factos.Event( - payload: event, - descriptor: factos.EventDescriptor( - type_: type_, - version: version, - tags: tags, - metadata: metadata, - ), +factos.Matching(items: [ + factos.Item( + types: [factos.EventType("TicketSold")], + tags: [factos.Tag("event:gleamconf-2026")], ), -) +]) ``` -`position` is the event's global log position. `NoPosition` means no global -position was observed; `SequencePosition(Int)` carries a backend-specific -ordered position. Factos deliberately has no stream or per-stream revision in -its core record model. +Items are OR-combined. Types inside one item are OR-combined; tags are +AND-combined. -## Projection folds +A backend folds the matching records and returns a `Context`. Its +`FailIfEventsMatch` condition prevents appending if a relevant fact appeared +after the observed global position. -A read model is ordinary pure application code. Fold the events with the state -and evolution function that the projection needs: +## Events and records -```gleam -fn count_sold_tickets(events: List(Event)) -> Int { - list.fold(events, 0, fn(count, event) { - case event { - TicketSold(buyer: _) -> count + 1 - } - }) -} -``` +`Event(payload)` contains the domain payload and its descriptor: event type, +version, tags, and metadata. -Factos does not add a `View` wrapper because that record would enforce no -invariant and would not decide where projected state is stored. +`Recorded(event)` adds the application event id and globally ordered sequence +position. Factos has no core stream or per-stream revision model. -## Effect derivation +## Simulation -Follow-up work is likewise an application-owned pure function over a recorded -event: +`factos/simulate` runs deterministic domain and codec scenarios without a +database: ```gleam -fn ticket_effects(recorded: factos.Recorded(Event)) -> List(Effect) { - case recorded.event.payload { - TicketSold(buyer:) -> [ - AnnounceTicketSale(buyer:, position: recorded.position), - ] - } -} +simulate.new(model) +|> simulate.given([TicketSold(buyer: "renata")]) +|> simulate.dispatch( + decision_context: sale_context(), + command: BuyTicket(buyer: "lucy"), +) +|> simulate.assert_events([ + TicketSold(buyer: "renata"), + TicketSold(buyer: "lucy"), +]) +|> simulate.assert_errors([]) ``` -Keep effect derivation separate from execution. Application or infrastructure -code decides whether work is executed after commit, persisted to an outbox, -retried, or skipped during replay. - -## What stays outside core - -The core package intentionally does not solve: +Simulation does not prove backend isolation or concurrency. Use backend +integration tests for those guarantees. -- database selection; -- serialization; -- schema evolution; -- projection repositories; -- durable effect delivery; -- retries and dead letters; -- subscription scheduling and historical catch-up workers; -- deployment topology. +## Outside core -Backends and applications own those decisions. +Applications and backends own schema migrations, projections, durable jobs, +external effects, retries, dead letters, and deployment topology. diff --git a/docs/domain-driven-design.md b/docs/domain-driven-design.md index 4610673..0575d6b 100644 --- a/docs/domain-driven-design.md +++ b/docs/domain-driven-design.md @@ -1,15 +1,11 @@ # Factos and Domain-Driven Design -Factos is not a Domain-Driven Design framework. It is a small event-sourcing -library whose shape fits DDD-style modelling. +Factos is not a DDD framework. It keeps business commands, accepted facts, and +their consistency boundaries explicit. -The useful connection is this: DDD asks the code to express business decisions in -the language of the domain, and Factos asks each command to name the facts needed -for that decision. +## Domain language -## Domain language stays in the application - -Your application defines the business language: +Applications define ordinary Gleam types: ```gleam pub type Command { @@ -25,124 +21,41 @@ pub type DomainError { } ``` -Factos does not provide generic `Command`, `Event`, or `Aggregate` interfaces. -Gleam custom types are clearer and give exhaustive pattern matching when the -model changes. +A pure `Decider` folds relevant events into temporary decision state and accepts +or rejects a command. Factos does not require generic command, event, or +aggregate interfaces. -## Commands, facts, and state +## Invariants define context -DDD models often become clearer when intent, accepted facts, and decision state -are separated. +For each command ask: -- A command is intent: `BuyTicket(buyer: "renata")`. -- An event is an accepted fact: `TicketSold(buyer: "renata")`. -- State is what the decision needs to know: - `TicketWindow(capacity: 100, sold: 42)`. -- A domain error explains business rejection: `SoldOut(capacity: 100)`. +> Which accepted facts can change this answer? -Factos represents this with a `Decider`: - -```gleam -factos.decider( - initial: TicketWindow(capacity: 100, sold: 0), - decide: decide, - evolve: evolve, -) -``` - -The decider is pure. It does not query a database, send emails, publish messages, -or mutate projections. - -## Invariants define the context - -An invariant is a rule that must remain true when the system accepts a change. - -Examples: - -- a ticket sale cannot exceed capacity; -- a username cannot be registered twice; -- an account cannot spend more than its available balance; -- an invoice cannot be paid after it was voided. - -The key design question is: - -> Which facts can change the answer to this command? - -Factos calls that set of facts the command context. - -For a ticket-sale capacity rule, the decision context can select every ticket -sale for one event: +That set is the `DecisionContext`. ```gleam factos.Matching(items: [ - factos.item( - types: [factos.event_type("TicketSold")], - tags: [factos.tag("event:gleamconf-2026")], + factos.Item( + types: [factos.EventType("TicketSold")], + tags: [factos.Tag("event:gleamconf-2026")], ), ]) ``` -`NoContext` explicitly marks a command that ignores history. `AllEvents` marks a -command whose answer can change with any accepted fact. `Matching(items:)` -expresses a narrower type-and-tag boundary such as the ticket rule above. - -That context is more precise than saying every command must belong to one -aggregate root. - -## Bounded contexts and tags - -DDD bounded contexts define where a model and its language are valid. Factos tags -are not a replacement for that modelling work, but they make the storage boundary -explicit. - -If the ticketing context needs to protect event capacity, write tags in the -ticketing language: - -```gleam -factos.tag("event:gleamconf-2026") -``` - -If the billing context needs account facts, use billing tags: - -```gleam -factos.tag("account:acct_123") -``` - -The backend treats tags as strings, but the application should treat them as part -of the domain contract. - -## Side effects stay outside decisions - -A domain decision should not send email, call a payment gateway, write files, or -publish messages. - -Factos gives two pure tools after facts exist: - -- `View`: fold facts into read-side state; -- `Reactor`: turn committed recorded facts into effect values. - -A reactor can say that a ticket-sale announcement is needed: - -```gleam -pub type Effect { - AnnounceTicketSale(buyer: String, position: factos.SequencePosition) -} -``` +Use `NoContext` when history is irrelevant and `AllEvents` only for genuinely +global rules. Prefer the narrowest context that protects the invariant. -The application decides how to execute or persist that effect. This keeps replay, -retry, and rebuild logic outside the domain decision. +Tags expose the domain values required for selection. They are part of the +persisted domain contract, not a replacement for bounded-context modelling. -## What Factos does not decide for you +## Effects stay outside decisions -Factos does not tell you: +Deciders must not perform database, network, file, or process IO. Retrying a +backend transaction may execute them more than once. -- how to split bounded contexts; -- what events should exist; -- what tag names your domain should use; -- how to version event payloads; -- where to store projections; -- how to deliver side effects; -- how to design retry or dead-letter policy. +Derive projections and durable work from committed events. Use transactional +subscriptions for atomic database writes, then perform external IO through an +application-owned delivery mechanism. -Those are application design decisions. Factos provides the small set of types -and backend contracts that let those decisions stay explicit. +Factos does not prescribe bounded contexts, event names, tag conventions, +projection storage, payload migrations, or effect delivery policy. diff --git a/docs/event-sourcing.md b/docs/event-sourcing.md index 3144c33..98ffd30 100644 --- a/docs/event-sourcing.md +++ b/docs/event-sourcing.md @@ -1,141 +1,67 @@ # Event Logs and Command Dispatch -Factos stores events and provides shared types for command dispatch. Those ideas -are related but not identical: +Factos stores accepted events, not commands or mutable domain objects. -- an event is an accepted fact: `TicketSold(buyer: "renata")`; -- a command is intent: `BuyTicket(buyer: "renata")`. - -A backend stores accepted events. A `Decider` and the backend's dispatch API -provide one standard way to process commands on top of that log. - -## What a backend persists - -A backend such as `factos_pog` persists: +A backend record contains: - an application event id; - a global sequence position; - event type and schema version; -- tags used for selective decision contexts; -- application metadata; -- the encoded event payload. - -There is no stream or per-stream revision in the Factos event model. Facts are -ordered in one append-only log. A decoded `factos.Recorded(event)` contains the -id, global position, and an event envelope containing the domain payload and -descriptor. +- tags and metadata; +- the encoded JSON payload. -Factos does not persist commands. The core package does not maintain materialized -views or execute external side effects. +There are no core streams or per-stream revisions. -## What dispatch does - -A dispatch combines an event log with a command-side decision: +## Dispatch ```text -command + selected previous facts -> new facts or domain error -``` - -Every command names its dependency with a `DecisionContext`: - -```gleam -fn sale_context() -> factos.DecisionContext { - factos.Matching(items: [ - factos.item( - types: [factos.event_type("TicketSold")], - tags: [factos.tag("event:gleamconf-2026")], - ), - ]) -} +command + relevant accepted facts -> new events or domain error ``` -The backend reads matching records, decodes and folds them into temporary state, -calls the decider, and attempts to append the resulting events. - -Use: - -- `NoContext` when the decision intentionally ignores history; -- `AllEvents` when every prior fact can affect the decision; -- `Matching(items:)` for a selective type-and-tag boundary. +A dispatch: -For `Matching`, items are OR-combined. Within one item, types are OR-combined and -tags are AND-combined. Empty types or tags remove that dimension's restriction; -an empty item list matches no events. +1. reads the command's `DecisionContext`; +2. decodes and folds matching events; +3. runs the model's pure decider; +4. appends only if the context remains stable; +5. returns committed `Recorded` events. -## What makes an append safe - -A context read observes the highest global position among its selected events. -The shared append condition is: +The application supplies the context with each command: ```gleam -factos.FailIfEventsMatch( - decision_context: sale_context(), - after: position, +configuration +|> factos_pog.dispatch( + command, + decision_context: decision_context(command), + event_id: new_event_id, ) ``` -It means: +## Consistency -> Do not append these new facts if another fact selected by the same decision -> context was accepted after the position used for the decision. - -This lets the consistency boundary follow the business rule instead of a fixed -aggregate or stream. `AllEvents` creates a global boundary. `Matching` creates a -selective boundary. `NoContext` matches nothing and therefore represents an -unconditional append. - -A concrete backend decides how to protect this condition. For example, -`factos_pog` uses PostgreSQL serializable transactions and retries retryable -serialization or deadlock conflicts. - -## Projections are application code - -A read model is an ordinary pure fold, not a special core type or a durable -projection table: +A context records the highest selected global position and produces: ```gleam -fn count_sold_tickets(events: List(Event)) -> Int { - list.fold(events, 0, fn(count, event) { - case event { - TicketSold(buyer: _) -> count + 1 - } - }) -} -``` - -An application that needs a materialized view stores the result itself or -updates its projection from committed records. Projections remain recomputable -while stored history is decodable, so event versioning and encoder/decoder -compatibility remain application responsibilities. - -## Effects are application code - -Derive effect values with an ordinary pure function: - -```gleam -fn ticket_effects(recorded: factos.Recorded(Event)) -> List(Effect) { - case recorded.event.payload { - TicketSold(buyer:) -> [ - AnnounceTicketSale(buyer:, position: recorded.position), - ] - } -} +factos.FailIfEventsMatch( + decision_context: context, + after: position, +) ``` -Keeping derivation separate from execution prevents replay from implicitly -sending email, charging a card, or publishing a webhook. +The backend must reject or retry the append if a matching event appeared after +that position. This lets the consistency boundary follow the business rule +rather than a fixed aggregate. -## Where Factos is opinionated +- `NoContext` is unconditional. +- `AllEvents` creates a global boundary. +- `Matching` creates a selective type-and-tag boundary. -Factos is opinionated that: +## Projections and effects -- accepted facts form an append-only globally ordered log; -- command decisions are pure; -- every dispatch names a decision context; -- context reads produce append conditions; -- backends return committed records; -- projections and effects remain explicit application code. +Projections are application-owned folds over committed events. Strong +transactional work can be attached with `Subscription`; backend-specific +configuration determines how it shares the append transaction. -It does not prescribe event or command names, payload encoding, projection -storage, subscription infrastructure, effect retry policy, or deployment -topology. +External IO does not belong in a retrying decision or transaction callback. +Persist durable intent atomically, then execute it with an application-owned +retry and idempotency policy. diff --git a/examples/course_subscriptions/CHANGELOG.md b/examples/course_subscriptions/CHANGELOG.md new file mode 100644 index 0000000..39130df --- /dev/null +++ b/examples/course_subscriptions/CHANGELOG.md @@ -0,0 +1 @@ +# course_subscriptions changelog diff --git a/examples/course_subscriptions/README.md b/examples/course_subscriptions/README.md index e7ff144..a97daf6 100644 --- a/examples/course_subscriptions/README.md +++ b/examples/course_subscriptions/README.md @@ -1,6 +1,6 @@ # Course subscriptions -A runnable Gleam, Factos, and PostgreSQL implementation of the +A runnable Gleam and Factos simulation of the [DCB course subscriptions example](https://dcb.events/examples/course-subscriptions/). ## Challenge @@ -25,12 +25,9 @@ package sets the same configurable constraint to five. two items: the course history needed to calculate capacity and occupancy, and the student's history needed to calculate their subscription count. -Factos folds those matching events into command-specific state. A shared -`factos.Dispatch(Event)` is built with `factos.new_dispatch`, then -`factos_pog.dispatch` runs the read, decision, and conditional append in a -serializable PostgreSQL transaction. Concurrent attempts therefore enforce both -sides of the constraint without a read model, reservation saga, or aggregate -spanning every course and student. +Factos folds matching events into command-specific state. The example uses +`factos/simulate` to run deterministic `given`, `dispatch`, `assert_events`, and +`assert_errors` scenarios without a database. The package also demonstrates: @@ -38,45 +35,35 @@ The package also demonstrates: - course-capacity changes; - domain errors for missing, full, unchanged, and duplicate cases; - synchronized concurrent subscription attempts against the same course; -- a JSON encoder returning `factos.Event(json.Json)` and a decoder accepting - `factos.Recorded(String)`. +- a JSON encoder returning `factos.Event(json.Json)` and a schema decoder + selected by event type and version. -The dispatch builder receives the encoder and decoder directly. The decoder -checks the nested event descriptor before parsing the stored payload. Malformed -payloads and unsupported event types or versions reject the stored record during -row decoding. +The dispatch builder receives the encoder and decoder factory directly. +Malformed payloads become `factos.DecodeError`; unsupported event types or +versions become `factos.InvalidSchema`. -The public dispatch result uses the backend-specific error alias: -`Result(factos.Dispatch(Event), factos_pog.Error(Error, Nil))`. `Nil` is the -subscription-error type; the alias fixes the store error to `pog.QueryError` -and carries the rejected `factos.Recorded(String)` as its decode error. +The simulator exercises the same JSON encoder, decoder factory, decider, and +decision contexts exposed by the model. The command-to-state flow is diagrammed in the module documentation in [`src/course_subscription.gleam`](src/course_subscription.gleam). ## Run it -Requirements: [Gleam](https://gleam.run/) and a Docker-compatible container -runtime. - -From this directory: +Requirements: [Gleam](https://gleam.run/). ```sh -gleam deps download gleam test -gleam dev +gleam run -m course_subscriptions_dev ``` -Both commands start an isolated PostgreSQL container with Testcontainers, apply -the Factos Pog event-store migration, exercise the example, and remove the -container. No developer-managed database is required. +No database or container runtime is required. ## Package layout - [`src/course_subscription.gleam`](src/course_subscription.gleam) contains the - commands, events, decider, DCB contexts, JSON encoder, decoder, and - PostgreSQL-backed dispatch API. + commands, events, decider, DCB contexts, JSON encoder, decoder, and model. - [`test/course_subscriptions_test.gleam`](test/course_subscriptions_test.gleam) - verifies the source scenarios and concurrent constraint enforcement. + verifies capacity, duplicate definition, and duplicate student scenarios. - [`dev/course_subscriptions_dev.gleam`](dev/course_subscriptions_dev.gleam) is the runnable demonstration. diff --git a/examples/course_subscriptions/dev/course_subscriptions_dev.gleam b/examples/course_subscriptions/dev/course_subscriptions_dev.gleam deleted file mode 100644 index a360122..0000000 --- a/examples/course_subscriptions/dev/course_subscriptions_dev.gleam +++ /dev/null @@ -1,455 +0,0 @@ -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.Dispatch(course_subscription.Event), - factos_pog.Error(course_subscription.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 assert Ok(_) = - course_subscription.dispatch( - connection, - course_subscription.DefineCourse(course_id: "c1", capacity: 2), - uuid.v4_string, - ) - let assert Error(factos.DomainError(course_subscription.CourseAlreadyExists( - course_id: "c1", - ))) = - course_subscription.dispatch( - connection, - course_subscription.DefineCourse(course_id: "c1", capacity: 15), - uuid.v4_string, - ) - let assert Error(factos.DomainError(course_subscription.CourseDoesNotExist( - course_id: "c0", - ))) = - course_subscription.dispatch( - connection, - course_subscription.ChangeCourseCapacity( - course_id: "c0", - new_capacity: 15, - ), - uuid.v4_string, - ) - let assert Error(factos.DomainError(course_subscription.CapacityUnchanged( - capacity: 2, - ))) = - course_subscription.dispatch( - connection, - course_subscription.ChangeCourseCapacity(course_id: "c1", new_capacity: 2), - uuid.v4_string, - ) - let assert Ok(_) = - course_subscription.dispatch( - connection, - course_subscription.ChangeCourseCapacity(course_id: "c1", new_capacity: 3), - uuid.v4_string, - ) - - let assert Error(factos.DomainError(course_subscription.CourseDoesNotExist( - course_id: "missing", - ))) = - course_subscription.dispatch( - connection, - course_subscription.SubscribeStudentToCourse( - student_id: "s1", - course_id: "missing", - ), - uuid.v4_string, - ) - require_command( - connection, - course_subscription.SubscribeStudentToCourse( - student_id: "s1", - course_id: "c1", - ), - ) - let assert Error(factos.DomainError( - course_subscription.StudentAlreadySubscribed, - )) = - course_subscription.dispatch( - connection, - course_subscription.SubscribeStudentToCourse( - student_id: "s1", - course_id: "c1", - ), - uuid.v4_string, - ) - require_command( - connection, - course_subscription.SubscribeStudentToCourse( - student_id: "s2", - course_id: "c1", - ), - ) - require_command( - connection, - course_subscription.SubscribeStudentToCourse( - student_id: "s3", - course_id: "c1", - ), - ) - let assert Error(factos.DomainError(course_subscription.CourseFullyBooked( - course_id: "c1", - ))) = - course_subscription.dispatch( - connection, - 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, - course_subscription.DefineCourse(course_id:, capacity: 10), - ) - }) - ["c2", "c3", "c4", "c5", "c6"] - |> list.each(fn(course_id) { - require_command( - connection, - course_subscription.SubscribeStudentToCourse( - student_id: "limited", - course_id:, - ), - ) - }) - let assert Error(factos.DomainError(course_subscription.StudentCourseLimitReached( - limit: 5, - ))) = - course_subscription.dispatch( - connection, - course_subscription.SubscribeStudentToCourse( - student_id: "limited", - course_id: "c7", - ), - uuid.v4_string, - ) - - require_command( - connection, - course_subscription.DefineCourse(course_id: "c8", capacity: 1), - ) - let concurrent_results = run_concurrent_final_seat(connection) - 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_after( - connection, - factos.AllEvents, - factos.NoPosition, - 100, - course_subscription.decode_event, - ) - 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 require_command( - connection: pog.Connection, - command: course_subscription.Command, -) -> Nil { - let assert Ok(_) = - course_subscription.dispatch(connection, command, uuid.v4_string) - Nil -} - -fn run_concurrent_final_seat( - connection: pog.Connection, -) -> List( - Result( - factos.Dispatch(course_subscription.Event), - factos_pog.Error(course_subscription.Error, Nil), - ), -) { - let messages = process.new_subject() - start_worker(connection, messages:, worker: "first", student_id: "s8a") - start_worker(connection, 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, - 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, - 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.Dispatch(course_subscription.Event), - factos_pog.Error(course_subscription.Error, Nil), -) { - let assert Ok(message) = process.receive(messages, within: 10_000) - let assert WorkerFinished(worker: _, result:) = message - result -} - -fn is_accepted( - result: Result( - factos.Dispatch(course_subscription.Event), - factos_pog.Error(course_subscription.Error, Nil), - ), -) -> Bool { - case result { - Ok(_) -> True - Error(_) -> False - } -} - -fn is_fully_booked( - result: Result( - factos.Dispatch(course_subscription.Event), - factos_pog.Error(course_subscription.Error, Nil), - ), -) -> Bool { - case result { - Error(factos.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.payload { - 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.payload { - 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.payload { - 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.payload { - 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 <> "/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/course_subscriptions/gleam.toml b/examples/course_subscriptions/gleam.toml index 901d028..e0f456f 100644 --- a/examples/course_subscriptions/gleam.toml +++ b/examples/course_subscriptions/gleam.toml @@ -3,16 +3,8 @@ 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 index 163c8b0..cc13d88 100644 --- a/examples/course_subscriptions/manifest.toml +++ b/examples/course_subscriptions/manifest.toml @@ -7,40 +7,14 @@ # 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 = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], source = "local", path = "../.." }, - { name = "factos_pog", version = "2.0.0", build_tools = ["gleam"], requirements = ["exception", "factos", "gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib", "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 = "factos", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], source = "local", path = "../.." }, { 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.3.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "DE4CA6850842F0266EE95317A25DD6A0A0F20CDFAB7C0ADC2E63251D7C3C72EC" }, { 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.10.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "56539216E4C4B1748714652AB38F0BD16B9101F61DB62769FDC7CD42A8E5E833" }, { 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 index 08c29b7..b0d11b7 100644 --- a/examples/course_subscriptions/src/course_subscription.gleam +++ b/examples/course_subscriptions/src/course_subscription.gleam @@ -2,17 +2,13 @@ //// Consistency Boundaries. //// //// This implements the example at -//// https://dcb.events/examples/course-subscriptions/ using Factos and -//// PostgreSQL. +//// https://dcb.events/examples/course-subscriptions/ using Factos simulation. import factos -import factos/factos_pog import gleam/dynamic/decode import gleam/json -import gleam/result -import pog -const student_course_limit = 5 +const student_course_limit: Int = 5 pub type Event { CourseDefined(course_id: String, capacity: Int) @@ -35,22 +31,22 @@ pub type Error { StudentCourseLimitReached(limit: Int) } -type DefinitionStatus { +pub type DefinitionStatus { Undefined Defined } -type CourseState { +pub type CourseState { CourseMissing CoursePresent(capacity: Int) } -type SubscriptionStatus { +pub type SubscriptionStatus { NotSubscribed Subscribed } -type State { +pub type State { DefiningCourse(definition: DefinitionStatus) ChangingCourseCapacity(course: CourseState) SubscribingStudent( @@ -207,11 +203,19 @@ fn evolve(state: State, event: Event) -> State { } } +const student_subscribed_to_course = factos.EventType( + "StudentSubscribedToCourse", +) + +const course_capacity_changed = factos.EventType("CourseCapacityChanged") + +const course_defined = factos.EventType("CourseDefined") + fn encode(event: Event) -> factos.Event(json.Json) { case event { CourseDefined(course_id:, capacity:) -> - factos.new_event( - type_: factos.event_type("CourseDefined"), + factos.event( + type_: course_defined, version: 1, data: json.object([ #("course_id", json.string(course_id)), @@ -219,11 +223,11 @@ fn encode(event: Event) -> factos.Event(json.Json) { ]), ) |> factos.with_tags(tags: [ - factos.tag("course:" <> course_id), + factos.Tag("course:" <> course_id), ]) CourseCapacityChanged(course_id:, new_capacity:) -> - factos.new_event( - type_: factos.event_type("CourseCapacityChanged"), + factos.event( + type_: course_capacity_changed, version: 1, data: json.object([ #("course_id", json.string(course_id)), @@ -231,11 +235,11 @@ fn encode(event: Event) -> factos.Event(json.Json) { ]), ) |> factos.with_tags(tags: [ - factos.tag("course:" <> course_id), + factos.Tag("course:" <> course_id), ]) StudentSubscribedToCourse(student_id:, course_id:) -> - factos.new_event( - type_: factos.event_type("StudentSubscribedToCourse"), + factos.event( + type_: student_subscribed_to_course, version: 1, data: json.object([ #("student_id", json.string(student_id)), @@ -243,29 +247,23 @@ fn encode(event: Event) -> factos.Event(json.Json) { ]), ) |> factos.with_tags(tags: [ - factos.tag("student:" <> student_id), - factos.tag("course:" <> course_id), + factos.Tag("student:" <> student_id), + factos.Tag("course:" <> course_id), ]) } } -pub fn decode_event( - stored: factos.Recorded(String), -) -> Result(Event, factos.Recorded(String)) { - case - factos.event_type_to_string(stored.event.descriptor.type_), - stored.event.descriptor.version - { - "CourseDefined", 1 -> - json.parse(stored.event.payload, using: course_defined_decoder()) - |> result.replace_error(stored) - "CourseCapacityChanged", 1 -> - json.parse(stored.event.payload, using: course_capacity_changed_decoder()) - |> result.replace_error(stored) - "StudentSubscribedToCourse", 1 -> - json.parse(stored.event.payload, using: student_subscribed_decoder()) - |> result.replace_error(stored) - _, _ -> Error(stored) +pub fn decode( + type_: factos.EventType, + version: Int, +) -> Result(decode.Decoder(Event), Nil) { + case version { + 1 if type_ == course_defined -> Ok(course_defined_decoder()) + 1 if type_ == course_capacity_changed -> + Ok(course_capacity_changed_decoder()) + 1 if type_ == student_subscribed_to_course -> + Ok(student_subscribed_decoder()) + _ -> Error(Nil) } } @@ -287,53 +285,52 @@ fn student_subscribed_decoder() -> decode.Decoder(Event) { decode.success(StudentSubscribedToCourse(student_id:, course_id:)) } -pub fn dispatch( - connection: pog.Connection, - command: Command, - event_id: fn() -> String, -) -> Result(factos.Dispatch(Event), factos_pog.Error(Error, Nil)) { - factos.new_dispatch( - connection:, - decider: factos.decider(initial: initial(command), decide:, evolve:), - decision_context: decision_context(command), - encode: encode, - decode: decode_event, +pub fn model() { + factos.model( + decider: fn(command) { + factos.decider(initial: initial(command), decide:, evolve:) + }, + encode:, + decode:, ) - |> factos_pog.dispatch(command, event_id:) } -fn decision_context(command: Command) -> factos.DecisionContext { +pub fn decision_context(command: Command) -> factos.DecisionContext { case command { DefineCourse(course_id:, capacity: _) -> factos.Matching([ - factos.item(types: [factos.event_type("CourseDefined")], tags: [ - factos.tag("course:" <> course_id), + factos.Item(types: [course_defined], tags: [ + course_tag(course_id), ]), ]) ChangeCourseCapacity(course_id:, new_capacity: _) -> factos.Matching([ - factos.item( - types: [ - factos.event_type("CourseDefined"), - factos.event_type("CourseCapacityChanged"), - ], - tags: [factos.tag("course:" <> course_id)], - ), + factos.Item(types: [course_defined, course_capacity_changed], tags: [ + course_tag(course_id), + ]), ]) SubscribeStudentToCourse(student_id:, course_id:) -> factos.Matching([ - factos.item( + factos.Item( types: [ - factos.event_type("CourseDefined"), - factos.event_type("CourseCapacityChanged"), - factos.event_type("StudentSubscribedToCourse"), + course_defined, + course_capacity_changed, + student_subscribed_to_course, ], - tags: [factos.tag("course:" <> course_id)], + tags: [course_tag(course_id)], ), - factos.item( - types: [factos.event_type("StudentSubscribedToCourse")], - tags: [factos.tag("student:" <> student_id)], + factos.Item( + types: [factos.EventType("StudentSubscribedToCourse")], + tags: [student_tag(student_id)], ), ]) } } + +fn course_tag(course_id: String) -> factos.Tag { + factos.Tag("course:" <> course_id) +} + +fn student_tag(student_id: String) -> factos.Tag { + factos.Tag("student:" <> student_id) +} diff --git a/examples/course_subscriptions/test/course_subscriptions_test.gleam b/examples/course_subscriptions/test/course_subscriptions_test.gleam index 401f94a..7323670 100644 --- a/examples/course_subscriptions/test/course_subscriptions_test.gleam +++ b/examples/course_subscriptions/test/course_subscriptions_test.gleam @@ -1,26 +1,77 @@ -import course_subscriptions_dev +import course_subscription +import factos +import factos/simulate import gleeunit -pub type Timeout(a) { - Timeout(time: Int, function: fn() -> a) +pub fn main() { + gleeunit.main() } -pub fn main() -> Nil { - gleeunit.main() +pub fn course_accepts_students_until_capacity_test() { + let define = course_subscription.DefineCourse("course-1", 1) + let first = + course_subscription.SubscribeStudentToCourse("student-1", "course-1") + let full = + course_subscription.SubscribeStudentToCourse("student-2", "course-1") + + simulate.new(course_subscription.model()) + |> simulate.dispatch( + decision_context: course_subscription.decision_context(define), + command: define, + ) + |> simulate.dispatch( + decision_context: course_subscription.decision_context(first), + command: first, + ) + |> simulate.dispatch( + decision_context: course_subscription.decision_context(full), + command: full, + ) + |> simulate.assert_events([ + course_subscription.CourseDefined("course-1", 1), + course_subscription.StudentSubscribedToCourse("student-1", "course-1"), + ]) + |> simulate.assert_errors([ + factos.DomainError(course_subscription.CourseFullyBooked("course-1")), + ]) } -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 +pub fn duplicate_course_definition_is_rejected_test() { + let define = course_subscription.DefineCourse("course-1", 2) + + simulate.new(course_subscription.model()) + |> simulate.dispatch( + decision_context: course_subscription.decision_context(define), + command: define, + ) + |> simulate.dispatch( + decision_context: course_subscription.decision_context(define), + command: define, + ) + |> simulate.assert_errors([ + factos.DomainError(course_subscription.CourseAlreadyExists("course-1")), + ]) +} + +pub fn student_cannot_subscribe_twice_test() { + let define = course_subscription.DefineCourse("course-1", 2) + let subscribe = + course_subscription.SubscribeStudentToCourse("student-1", "course-1") + + simulate.new(course_subscription.model()) + |> simulate.dispatch( + decision_context: course_subscription.decision_context(define), + command: define, + ) + |> simulate.dispatch( + decision_context: course_subscription.decision_context(subscribe), + command: subscribe, + ) + |> simulate.dispatch( + decision_context: course_subscription.decision_context(subscribe), + command: subscribe, + ) + |> simulate.assert_errors([ + factos.DomainError(course_subscription.StudentAlreadySubscribed), + ]) } diff --git a/examples/dynamic_product_price/CHANGELOG.md b/examples/dynamic_product_price/CHANGELOG.md new file mode 100644 index 0000000..29e2770 --- /dev/null +++ b/examples/dynamic_product_price/CHANGELOG.md @@ -0,0 +1 @@ +# dynamic_product_price changelog diff --git a/examples/dynamic_product_price/README.md b/examples/dynamic_product_price/README.md index 179721f..8045726 100644 --- a/examples/dynamic_product_price/README.md +++ b/examples/dynamic_product_price/README.md @@ -1,6 +1,6 @@ # Dynamic product price -A runnable Gleam, Factos, and PostgreSQL implementation of the +A runnable Gleam and Factos simulation of the [DCB dynamic product price example](https://dcb.events/examples/dynamic-product-price/). ## Challenge @@ -17,11 +17,8 @@ contains only the price history relevant to that cart. The decider reconstructs the stable price and all prices still inside the grace period, then validates every displayed price before emitting one `ProductsOrdered` event. -The shared `factos` API owns event envelopes, metadata, and the dispatch builder. -`factos_pog.dispatch` executes the dispatch in a serializable PostgreSQL -transaction. A concurrent price change selected by the decision context causes -the order to retry against the new history; a partially validated cart is never -persisted. +The shared `factos` API owns event envelopes, metadata, and model configuration. +`factos/simulate` runs deterministic price-history scenarios without a database. The package demonstrates: @@ -33,27 +30,21 @@ The package demonstrates: - JSON event payloads with product tags and recorded-minute metadata. The source example uses relative `minutesAgo` metadata for illustration. This -implementation stores an absolute `recorded_minute` in Factos event metadata. -The decoder reads that metadata from the nested event descriptor in -`factos.Recorded(String)` while parsing its JSON payload. The command supplies -`current_minute`, keeping the decider deterministic across serializable retries. +implementation stores the absolute `recorded_minute` in both the versioned +payload and Factos metadata. The schema decoder reconstructs the decision event +from the payload; metadata remains available for operational inspection. The +command supplies `current_minute`, keeping retries deterministic. ## Run it -Requirements: [Gleam](https://gleam.run/) and a Docker-compatible container -runtime. - -From this directory: +Requirements: [Gleam](https://gleam.run/). ```sh -gleam deps download gleam test -gleam dev +gleam run -m dynamic_product_price_dev ``` -Both commands start an isolated PostgreSQL container with Testcontainers, apply -the Factos Pog event-store migration, exercise the example, and remove the -container. No developer-managed database is required. +No database or container runtime is required. ## Package layout diff --git a/examples/dynamic_product_price/dev/dynamic_product_price_dev.gleam b/examples/dynamic_product_price/dev/dynamic_product_price_dev.gleam deleted file mode 100644 index e303f74..0000000 --- a/examples/dynamic_product_price/dev/dynamic_product_price_dev.gleam +++ /dev/null @@ -1,490 +0,0 @@ -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.Dispatch(dynamic_product_price.Event), - factos_pog.Error(dynamic_product_price.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) - - require_definition( - connection, - product_id: "p1", - price: 123, - recorded_minute: 0, - ) - require_invalid_order( - dynamic_product_price.dispatch( - connection, - 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, - 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, - dynamic_product_price.ChangeProductPrice( - product_id: "p1", - new_price: 134, - recorded_minute: 11, - ), - uuid.v4_string, - ) - require_order( - connection, - order_id: "old-within-grace", - items: [ - dynamic_product_price.OrderItem(product_id: "p1", displayed_price: 123), - ], - current_minute: 20, - ) - require_order( - connection, - 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, - 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, - order_id: "new-after-grace", - items: [ - dynamic_product_price.OrderItem(product_id: "p1", displayed_price: 134), - ], - current_minute: 22, - ) - - require_definition( - connection, - 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, - order_id: "valid-cart", - items: valid_cart, - current_minute: 22, - ) - require_invalid_order( - dynamic_product_price.dispatch( - connection, - 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, valid_cart) - let concurrent_acceptances = - list.count(concurrent_results, where: is_accepted) - assert concurrent_acceptances == 2 - - let assert Ok(events) = - factos_pog.read_after( - connection, - factos.AllEvents, - factos.NoPosition, - 100, - dynamic_product_price.decode_event, - ) - 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 require_definition( - connection: pog.Connection, - product_id product_id: String, - price price: Int, - recorded_minute recorded_minute: Int, -) -> Nil { - let assert Ok(_) = - dynamic_product_price.dispatch( - connection, - dynamic_product_price.DefineProduct(product_id:, price:, recorded_minute:), - uuid.v4_string, - ) - Nil -} - -fn require_order( - connection: pog.Connection, - 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, - 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.payload - == dynamic_product_price.ProductsOrdered(items: expected_items) - Nil -} - -fn require_invalid_order( - result: Result( - factos.Dispatch(dynamic_product_price.Event), - factos_pog.Error(dynamic_product_price.Error, Nil), - ), - product_id product_id: String, -) -> Nil { - let assert Error(factos.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, - items: List(dynamic_product_price.OrderItem), -) -> List( - Result( - factos.Dispatch(dynamic_product_price.Event), - factos_pog.Error(dynamic_product_price.Error, Nil), - ), -) { - let messages = process.new_subject() - start_worker( - connection, - messages:, - worker: "first", - order_id: "parallel-a", - items:, - ) - start_worker( - connection, - 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, - 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, - 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.Dispatch(dynamic_product_price.Event), - factos_pog.Error(dynamic_product_price.Error, Nil), -) { - let assert Ok(message) = process.receive(messages, within: 10_000) - let assert WorkerFinished(worker: _, result:) = message - result -} - -fn is_accepted( - result: Result( - factos.Dispatch(dynamic_product_price.Event), - factos_pog.Error(dynamic_product_price.Error, Nil), - ), -) -> Bool { - case result { - Ok(_) -> True - Error(_) -> False - } -} - -fn is_product_defined( - recorded: factos.Recorded(dynamic_product_price.Event), -) -> Bool { - case recorded.event.payload { - 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.payload { - 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.payload { - 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 <> "/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/dynamic_product_price/gleam.toml b/examples/dynamic_product_price/gleam.toml index 8568594..c22e085 100644 --- a/examples/dynamic_product_price/gleam.toml +++ b/examples/dynamic_product_price/gleam.toml @@ -3,16 +3,8 @@ 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 index 163c8b0..cc13d88 100644 --- a/examples/dynamic_product_price/manifest.toml +++ b/examples/dynamic_product_price/manifest.toml @@ -7,40 +7,14 @@ # 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 = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], source = "local", path = "../.." }, - { name = "factos_pog", version = "2.0.0", build_tools = ["gleam"], requirements = ["exception", "factos", "gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib", "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 = "factos", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], source = "local", path = "../.." }, { 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.3.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "DE4CA6850842F0266EE95317A25DD6A0A0F20CDFAB7C0ADC2E63251D7C3C72EC" }, { 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.10.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "56539216E4C4B1748714652AB38F0BD16B9101F61DB62769FDC7CD42A8E5E833" }, { 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 index 9001766..d837987 100644 --- a/examples/dynamic_product_price/src/dynamic_product_price.gleam +++ b/examples/dynamic_product_price/src/dynamic_product_price.gleam @@ -2,18 +2,22 @@ //// 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. +//// https://dcb.events/examples/dynamic-product-price/ using Factos simulation. +//// 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/dynamic/decode import gleam/int import gleam/json import gleam/list import gleam/result -import pog + +const product_defined = factos.EventType("ProductDefined") + +const product_price_changed = factos.EventType("ProductPriceChanged") + +const products_ordered = factos.EventType("ProductsOrdered") const price_grace_period_minutes = 10 @@ -43,12 +47,12 @@ pub type Error { InvalidPrice(product_id: String) } -type StablePrice { +pub type StablePrice { NoStablePrice StablePrice(price: Int) } -type ProductPrice { +pub type ProductPrice { ProductPrice( product_id: String, stable_price: StablePrice, @@ -56,12 +60,12 @@ type ProductPrice { ) } -type State { +pub type State { RecordingPriceFact OrderingProducts(current_minute: Int, products: List(ProductPrice)) } -type PriceAge { +pub type PriceAge { WithinGracePeriod OutsideGracePeriod } @@ -233,39 +237,44 @@ fn classify_age(current_minute: Int, recorded_minute: Int) -> PriceAge { } } -fn encode_event(event: Event) -> factos.Event(json.Json) { +fn encode(event: Event) -> factos.Event(json.Json) { case event { ProductDefined(product_id:, price:, recorded_minute:) -> - proposed_event( - type_: "ProductDefined", + factos.event( + type_: product_defined, data: json.object([ #("product_id", json.string(product_id)), #("price", json.int(price)), + #("recorded_minute", json.int(recorded_minute)), ]), - tags: [factos.tag("product:" <> product_id)], + version: 1, ) + |> factos.with_tags(tags: [product_tag(product_id)]) |> with_recorded_minute(recorded_minute) ProductPriceChanged(product_id:, new_price:, recorded_minute:) -> - proposed_event( - type_: "ProductPriceChanged", + factos.event( + type_: product_price_changed, data: json.object([ #("product_id", json.string(product_id)), #("new_price", json.int(new_price)), + #("recorded_minute", json.int(recorded_minute)), ]), - tags: [factos.tag("product:" <> product_id)], + version: 1, ) + |> factos.with_tags(tags: [product_tag(product_id)]) |> with_recorded_minute(recorded_minute) ProductsOrdered(items:) -> - proposed_event( - type_: "ProductsOrdered", + factos.event( + type_: products_ordered, 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) - }), + version: 1, ) + |> factos.with_tags({ + use item <- list.map(items) + product_tag(item.product_id) + }) } } @@ -277,15 +286,6 @@ fn encode_ordered_item(item: OrderedItem) -> json.Json { ]) } -fn proposed_event( - type_ type_name: String, - data data: json.Json, - tags tags: List(factos.Tag), -) -> factos.Event(json.Json) { - factos.new_event(type_: factos.event_type(type_name), version: 1, data:) - |> factos.with_tags(tags:) -} - fn with_recorded_minute( proposed: factos.Event(json.Json), recorded_minute: Int, @@ -298,86 +298,31 @@ fn with_recorded_minute( ) } -pub fn decode_event( - stored: factos.Recorded(String), -) -> Result(Event, factos.Recorded(String)) { - case - factos.event_type_to_string(stored.event.descriptor.type_), - stored.event.descriptor.version - { - "ProductDefined", 1 -> { - use recorded_minute <- result.try( - decode_recorded_minute(stored.event.descriptor.metadata) - |> result.replace_error(stored), - ) - json.parse( - stored.event.payload, - using: product_defined_decoder() - |> decode.map(fn(data) { - ProductDefined(product_id: data.0, price: data.1, recorded_minute:) - }), - ) - |> result.replace_error(stored) - } - "ProductPriceChanged", 1 -> { - use recorded_minute <- result.try( - decode_recorded_minute(stored.event.descriptor.metadata) - |> result.replace_error(stored), - ) - json.parse( - stored.event.payload, - using: product_price_changed_decoder() - |> decode.map(fn(data) { - ProductPriceChanged( - product_id: data.0, - new_price: data.1, - recorded_minute:, - ) - }), - ) - |> result.replace_error(stored) - } - "ProductsOrdered", 1 -> - json.parse( - stored.event.payload, - using: ordered_items_decoder() |> decode.map(ProductsOrdered), - ) - |> result.replace_error(stored) - _, _ -> Error(stored) +pub fn decode( + type_: factos.EventType, + version: Int, +) -> Result(decode.Decoder(Event), Nil) { + case version { + 1 if type_ == product_defined -> Ok(product_defined_decoder()) + 1 if type_ == product_price_changed -> Ok(product_price_changed_decoder()) + 1 if type_ == products_ordered -> + Ok(ordered_items_decoder() |> decode.map(ProductsOrdered)) + _ -> Error(Nil) } } -fn decode_recorded_minute( - metadata: factos.Metadata, -) -> Result(Int, json.DecodeError) { - use value <- result.try( - factos.metadata_get(metadata, recorded_minute_key) - |> result.map_error(fn(_) { metadata_int_decode_error("missing") }), - ) - int.parse(value) - |> result.map_error(fn(_) { metadata_int_decode_error(value) }) -} - -fn metadata_int_decode_error(found: String) -> json.DecodeError { - json.UnableToDecode([ - decode.DecodeError( - expected: recorded_minute_key <> " metadata containing an integer", - found:, - path: [], - ), - ]) -} - -fn product_defined_decoder() -> decode.Decoder(#(String, Int)) { +fn product_defined_decoder() -> decode.Decoder(Event) { use product_id <- decode.field("product_id", decode.string) use price <- decode.field("price", decode.int) - decode.success(#(product_id, price)) + use recorded_minute <- decode.field("recorded_minute", decode.int) + decode.success(ProductDefined(product_id:, price:, recorded_minute:)) } -fn product_price_changed_decoder() -> decode.Decoder(#(String, Int)) { +fn product_price_changed_decoder() -> decode.Decoder(Event) { use product_id <- decode.field("product_id", decode.string) use new_price <- decode.field("new_price", decode.int) - decode.success(#(product_id, new_price)) + use recorded_minute <- decode.field("recorded_minute", decode.int) + decode.success(ProductPriceChanged(product_id:, new_price:, recorded_minute:)) } fn ordered_items_decoder() -> decode.Decoder(List(OrderedItem)) { @@ -391,46 +336,44 @@ fn ordered_item_decoder() -> decode.Decoder(OrderedItem) { decode.success(OrderedItem(product_id:, price:)) } -pub fn dispatch( - connection: pog.Connection, - command: Command, - event_id: fn() -> String, -) -> Result(factos.Dispatch(Event), factos_pog.Error(Error, Nil)) { - factos.new_dispatch( - connection:, - decider: factos.decider(initial: initial(command), decide:, evolve:), - decision_context: decision_context(command), - encode: encode_event, - decode: decode_event, +pub fn model() { + factos.model( + decider: fn(command) { + factos.decider(initial: initial(command), decide:, evolve:) + }, + encode:, + decode:, ) - |> factos_pog.dispatch(command, event_id:) } -fn decision_context(command: Command) -> factos.DecisionContext { +pub fn decision_context(command: Command) -> factos.DecisionContext { case command { DefineProduct(product_id:, price: _, recorded_minute: _) | ChangeProductPrice(product_id:, new_price: _, recorded_minute: _) -> factos.Matching([ - factos.item( + factos.Item( types: [ - factos.event_type("ProductDefined"), - factos.event_type("ProductPriceChanged"), + product_defined, + product_price_changed, ], - tags: [factos.tag("product:" <> product_id)], + tags: [product_tag(product_id)], ), ]) OrderProducts(order_id: _, items:, current_minute: _) -> - items - |> list.map(fn(item) { + factos.Matching({ + use item <- list.map(items) let OrderItem(product_id:, displayed_price: _) = item - factos.item( + factos.Item( types: [ - factos.event_type("ProductDefined"), - factos.event_type("ProductPriceChanged"), + product_defined, + product_price_changed, ], - tags: [factos.tag("product:" <> product_id)], + tags: [product_tag(product_id)], ) }) - |> factos.Matching } } + +fn product_tag(product_id: String) -> factos.Tag { + factos.Tag("product:" <> product_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 index e2f011a..07bdaee 100644 --- a/examples/dynamic_product_price/test/dynamic_product_price_test.gleam +++ b/examples/dynamic_product_price/test/dynamic_product_price_test.gleam @@ -1,25 +1,113 @@ -import dynamic_product_price_dev +import dynamic_product_price +import factos +import factos/simulate import gleeunit -pub type Timeout(a) { - Timeout(time: Int, function: fn() -> a) +pub fn main() { + gleeunit.main() } -pub fn main() -> Nil { - gleeunit.main() +pub fn current_price_is_accepted_test() { + let define = + dynamic_product_price.DefineProduct( + product_id: "product-1", + price: 100, + recorded_minute: 10, + ) + let order = + dynamic_product_price.OrderProducts( + order_id: "order-1", + items: [dynamic_product_price.OrderItem("product-1", 100)], + current_minute: 20, + ) + + simulate.new(dynamic_product_price.model()) + |> simulate.dispatch( + decision_context: dynamic_product_price.decision_context(define), + command: define, + ) + |> simulate.assert_events([ + dynamic_product_price.ProductDefined("product-1", 100, 10), + ]) + |> simulate.dispatch( + decision_context: dynamic_product_price.decision_context(order), + command: order, + ) + |> simulate.assert_events([ + dynamic_product_price.ProductDefined("product-1", 100, 10), + dynamic_product_price.ProductsOrdered([ + dynamic_product_price.OrderedItem("product-1", 100), + ]), + ]) + |> simulate.assert_errors([]) } -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, +pub fn old_price_expires_after_grace_period_test() { + let define = dynamic_product_price.DefineProduct("product-1", 100, 10) + let change = dynamic_product_price.ChangeProductPrice("product-1", 120, 11) + let within_grace = + dynamic_product_price.OrderProducts( + order_id: "order-1", + items: [dynamic_product_price.OrderItem("product-1", 100)], + current_minute: 20, + ) + let expired = + dynamic_product_price.OrderProducts( + order_id: "order-2", + items: [dynamic_product_price.OrderItem("product-1", 100)], + current_minute: 22, ) - Nil + + simulate.new(dynamic_product_price.model()) + |> simulate.dispatch( + decision_context: dynamic_product_price.decision_context(define), + command: define, + ) + |> simulate.dispatch( + decision_context: dynamic_product_price.decision_context(change), + command: change, + ) + |> simulate.dispatch( + decision_context: dynamic_product_price.decision_context(within_grace), + command: within_grace, + ) + |> simulate.assert_events([ + dynamic_product_price.ProductDefined("product-1", 100, 10), + dynamic_product_price.ProductPriceChanged("product-1", 120, 11), + dynamic_product_price.ProductsOrdered([ + dynamic_product_price.OrderedItem("product-1", 100), + ]), + ]) + |> simulate.assert_errors([]) + |> simulate.dispatch( + decision_context: dynamic_product_price.decision_context(expired), + command: expired, + ) + |> simulate.assert_errors([ + factos.DomainError(dynamic_product_price.InvalidPrice("product-1")), + ]) +} + +pub fn price_that_was_never_valid_is_rejected_test() { + let define = dynamic_product_price.DefineProduct("product-1", 100, 10) + let invalid = + dynamic_product_price.OrderProducts( + order_id: "order-1", + items: [dynamic_product_price.OrderItem("product-1", 99)], + current_minute: 20, + ) + + simulate.new(dynamic_product_price.model()) + |> simulate.dispatch( + decision_context: dynamic_product_price.decision_context(define), + command: define, + ) + |> simulate.assert_errors([]) + |> simulate.dispatch( + decision_context: dynamic_product_price.decision_context(invalid), + command: invalid, + ) + |> simulate.assert_errors([ + factos.DomainError(dynamic_product_price.InvalidPrice("product-1")), + ]) } diff --git a/examples/invoice_number/CHANGELOG.md b/examples/invoice_number/CHANGELOG.md new file mode 100644 index 0000000..119399a --- /dev/null +++ b/examples/invoice_number/CHANGELOG.md @@ -0,0 +1 @@ +# invoice_number changelog diff --git a/examples/invoice_number/README.md b/examples/invoice_number/README.md index 74f1f17..524b459 100644 --- a/examples/invoice_number/README.md +++ b/examples/invoice_number/README.md @@ -1,6 +1,6 @@ # Invoice number -A runnable Gleam, Factos, and PostgreSQL implementation of the +A runnable Gleam and Factos simulation of the [DCB invoice number example](https://dcb.events/examples/invoice-number/). ## Challenge @@ -15,11 +15,9 @@ produces the next number, starting at `1`. The same global type-based context is shared by every invoice-number allocation; there is no per-invoice stream or revision boundary. -Factos defines event envelopes, the dispatch builder, and shared result/error -types; Factos Pog executes the dispatch in a serializable PostgreSQL transaction. -Competing commands cannot commit the same number: a serialization conflict -retries one command against the newly committed invoice and assigns the -following number. +Factos defines the model and simulator used to prove sequential, gapless number +allocation from accepted history. Backend concurrency remains covered by the +`factos_pog` integration suite. The package demonstrates: @@ -35,27 +33,20 @@ possible optimizations for event stores that support them. ## Run it -Requirements: [Gleam](https://gleam.run/) and a Docker-compatible container -runtime. - -From this directory: +Requirements: [Gleam](https://gleam.run/). ```sh -gleam deps download gleam test -gleam dev +gleam run -m invoice_number_dev ``` -Both commands start an isolated PostgreSQL container with Testcontainers, apply -the Factos Pog event-store migration, exercise the example, and remove the -container. No developer-managed database is required. +No database or container runtime is required. ## Package layout - [`src/invoice_number.gleam`](src/invoice_number.gleam) contains the command, - event, sequence decider, global DCB context, encoder, decoder, and - PostgreSQL-backed dispatch API. + event, sequence decider, global DCB context, encoder, decoder, and model. - [`test/invoice_number_test.gleam`](test/invoice_number_test.gleam) verifies - sequential and concurrent allocation. + sequential allocation and seeded history. - [`dev/invoice_number_dev.gleam`](dev/invoice_number_dev.gleam) is the runnable demonstration. diff --git a/examples/invoice_number/dev/invoice_number_dev.gleam b/examples/invoice_number/dev/invoice_number_dev.gleam deleted file mode 100644 index cb9acaf..0000000 --- a/examples/invoice_number/dev/invoice_number_dev.gleam +++ /dev/null @@ -1,284 +0,0 @@ -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.Dispatch(invoice_number.Event), - factos_pog.Error(Nil, 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 assert Ok(first) = - invoice_number.dispatch( - connection, - invoice_number.CreateInvoice( - invoice_id: "i1", - invoice_data: invoice_number.InvoiceData(reference: "first"), - ), - uuid.v4_string, - ) - let assert Ok(second) = - invoice_number.dispatch( - connection, - 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) - |> 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_after( - connection, - factos.AllEvents, - factos.NoPosition, - 100, - invoice_number.decode_event, - ) - let stored_numbers = - events - |> list.map(fn(recorded) { - let invoice_number.InvoiceCreated(invoice_number:, invoice_data: _) = - recorded.event.payload - 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 dispatch_invoice_number( - dispatch: factos.Dispatch(invoice_number.Event), -) -> Int { - let assert [recorded] = dispatch.events - let invoice_number.InvoiceCreated(invoice_number:, invoice_data: _) = - recorded.event.payload - invoice_number -} - -fn run_concurrent_invoices( - connection: pog.Connection, -) -> List( - Result(factos.Dispatch(invoice_number.Event), factos_pog.Error(Nil, Nil)), -) { - let messages = process.new_subject() - start_worker( - connection, - messages:, - worker: "first", - invoice_id: "i3", - reference: "concurrent-a", - ) - start_worker( - connection, - 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, - 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, - 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.Dispatch(invoice_number.Event), factos_pog.Error(Nil, 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.payload - 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 <> "/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/invoice_number/gleam.toml b/examples/invoice_number/gleam.toml index 137cfcd..2ca1dcd 100644 --- a/examples/invoice_number/gleam.toml +++ b/examples/invoice_number/gleam.toml @@ -3,16 +3,8 @@ 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 index 163c8b0..cc13d88 100644 --- a/examples/invoice_number/manifest.toml +++ b/examples/invoice_number/manifest.toml @@ -7,40 +7,14 @@ # 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 = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], source = "local", path = "../.." }, - { name = "factos_pog", version = "2.0.0", build_tools = ["gleam"], requirements = ["exception", "factos", "gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib", "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 = "factos", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], source = "local", path = "../.." }, { 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.3.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "DE4CA6850842F0266EE95317A25DD6A0A0F20CDFAB7C0ADC2E63251D7C3C72EC" }, { 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.10.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "56539216E4C4B1748714652AB38F0BD16B9101F61DB62769FDC7CD42A8E5E833" }, { 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 index b3fe4e9..517769d 100644 --- a/examples/invoice_number/src/invoice_number.gleam +++ b/examples/invoice_number/src/invoice_number.gleam @@ -2,15 +2,12 @@ //// Consistency Boundaries. //// //// This implements the example at -//// https://dcb.events/examples/invoice-number/ using Factos and PostgreSQL. +//// https://dcb.events/examples/invoice-number/ using Factos simulation. import factos -import factos/factos_pog import gleam/dynamic/decode import gleam/int import gleam/json -import gleam/result -import pog pub type InvoiceData { InvoiceData(reference: String) @@ -21,25 +18,23 @@ pub type Event { } pub type Command { - CreateInvoice(invoice_id: String, invoice_data: InvoiceData) + CreateInvoice(invoice_data: InvoiceData) } -type State { +pub type State { CreatingInvoice(next_invoice_number: Int) } fn initial(command: Command) -> State { case command { - CreateInvoice(invoice_id: _, invoice_data: _) -> - CreatingInvoice(next_invoice_number: 1) + CreateInvoice(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:)]) + CreatingInvoice(next_invoice_number:), CreateInvoice(invoice_data:) -> + Ok([InvoiceCreated(invoice_number: next_invoice_number, invoice_data:)]) } } @@ -60,27 +55,20 @@ fn encode_event(event: Event) -> factos.Event(json.Json) { #("invoice_data", json.object([#("reference", json.string(reference))])), ]) - factos.new_event( - type_: factos.event_type("InvoiceCreated"), - version: 1, - data:, - ) + factos.event(type_: factos.EventType("InvoiceCreated"), version: 1, data:) |> factos.with_tags(tags: [ - factos.tag("invoice:" <> int.to_string(invoice_number)), + factos.Tag("invoice:" <> int.to_string(invoice_number)), ]) } pub fn decode_event( - stored: factos.Recorded(String), -) -> Result(Event, factos.Recorded(String)) { - case - factos.event_type_to_string(stored.event.descriptor.type_), - stored.event.descriptor.version - { - "InvoiceCreated", 1 -> - json.parse(stored.event.payload, using: event_decoder()) - |> result.replace_error(stored) - _, _ -> Error(stored) + type_: factos.EventType, + version: Int, +) -> Result(decode.Decoder(Event), Nil) { + let factos.EventType(name) = type_ + case name, version { + "InvoiceCreated", 1 -> Ok(event_decoder()) + _, _ -> Error(Nil) } } @@ -95,23 +83,18 @@ fn invoice_data_decoder() -> decode.Decoder(InvoiceData) { decode.success(InvoiceData(reference:)) } -pub fn dispatch( - connection: pog.Connection, - command: Command, - event_id: fn() -> String, -) -> Result(factos.Dispatch(Event), factos_pog.Error(Nil, Nil)) { - factos.new_dispatch( - connection:, - decider: factos.decider(initial: initial(command), decide:, evolve:), +pub fn model() { + factos.model( + decider: fn(command) { + factos.decider(initial: initial(command), decide:, evolve:) + }, encode: encode_event, decode: decode_event, - decision_context: decision_context(command), ) - |> factos_pog.dispatch(command, event_id:) } -fn decision_context(_command: Command) -> factos.DecisionContext { +pub fn decision_context(_command: Command) -> factos.DecisionContext { factos.Matching(items: [ - factos.item(types: [factos.event_type("InvoiceCreated")], tags: []), + factos.Item(types: [factos.EventType("InvoiceCreated")], tags: []), ]) } diff --git a/examples/invoice_number/test/invoice_number_test.gleam b/examples/invoice_number/test/invoice_number_test.gleam index 2c51b95..f3c9e2a 100644 --- a/examples/invoice_number/test/invoice_number_test.gleam +++ b/examples/invoice_number/test/invoice_number_test.gleam @@ -1,22 +1,64 @@ +import factos/simulate import gleeunit -import invoice_number_dev +import invoice_number -pub type Timeout(a) { - Timeout(time: Int, function: fn() -> a) +pub fn main() { + gleeunit.main() } -pub fn main() -> Nil { - gleeunit.main() +pub fn sequential_invoices_are_gapless_test() { + let first = + invoice_number.CreateInvoice(invoice_data: invoice_number.InvoiceData( + reference: "first", + )) + + let second = + invoice_number.CreateInvoice(invoice_data: invoice_number.InvoiceData( + reference: "second", + )) + + simulate.new(invoice_number.model()) + |> simulate.dispatch( + decision_context: invoice_number.decision_context(first), + command: first, + ) + |> simulate.dispatch( + decision_context: invoice_number.decision_context(second), + command: second, + ) + |> simulate.assert_events([ + invoice_number.InvoiceCreated(1, invoice_number.InvoiceData("first")), + invoice_number.InvoiceCreated( + invoice_number: 2, + invoice_data: invoice_number.InvoiceData(reference: "second"), + ), + ]) + |> simulate.assert_errors([]) } -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 +pub fn existing_history_sets_the_next_number_test() { + let command = + invoice_number.CreateInvoice(invoice_data: invoice_number.InvoiceData( + reference: "next", + )) + + simulate.new(invoice_number.model()) + |> simulate.given([ + invoice_number.InvoiceCreated(41, invoice_number.InvoiceData("existing")), + ]) + |> simulate.dispatch( + decision_context: invoice_number.decision_context(command), + command:, + ) + |> simulate.assert_events([ + invoice_number.InvoiceCreated( + invoice_number: 41, + invoice_data: invoice_number.InvoiceData(reference: "existing"), + ), + invoice_number.InvoiceCreated( + invoice_number: 42, + invoice_data: invoice_number.InvoiceData(reference: "next"), + ), + ]) + |> simulate.assert_errors([]) } diff --git a/examples/opt_in_token/CHANGELOG.md b/examples/opt_in_token/CHANGELOG.md new file mode 100644 index 0000000..a00ed7f --- /dev/null +++ b/examples/opt_in_token/CHANGELOG.md @@ -0,0 +1 @@ +# opt_in_token changelog diff --git a/examples/opt_in_token/README.md b/examples/opt_in_token/README.md index 2d657d5..b74b6f5 100644 --- a/examples/opt_in_token/README.md +++ b/examples/opt_in_token/README.md @@ -1,6 +1,6 @@ # Opt-in token -A runnable Gleam, Factos, and PostgreSQL implementation of the +A runnable Gleam and Factos simulation of the [DCB opt-in token example](https://dcb.events/examples/opt-in-token/). ## Challenge @@ -18,10 +18,8 @@ pending sign-up, unused token, or already-used token. The decider rejects an unknown email/token pair, a replayed token, or an expired token. A valid decision copies the name from the initiation event into -`SignUpConfirmed`. The Factos dispatch contract carries the event encoder, -decoder, and result types, while Factos Pog executes the read, decision, and -conditional append in a serializable PostgreSQL transaction. Concurrent -confirmations therefore cannot consume the same token twice. +`SignUpConfirmed`. The model exposes its codec, decider, and decision contexts +to `factos/simulate`. The package demonstrates: @@ -29,30 +27,24 @@ The package demonstrates: - matching a token to its email address; - one-time consumption under sequential and concurrent requests; - a 60-minute validity boundary; -- JSON event payloads with email and token tags; -- initiation-minute metadata decoding, including invalid-metadata rejection. +- versioned JSON payloads with email and token tags; +- initiation-minute payload decoding with operational metadata retained. The source example uses relative `minutesAgo` metadata for illustration. This -implementation stores an absolute `initiated_minute` in Factos event metadata -and supplies `current_minute` in the command, keeping the decider deterministic -across serializable retries. +implementation stores the absolute `initiated_minute` in the versioned payload +and Factos metadata, and supplies `current_minute` in the command so retries +remain deterministic. ## Run it -Requirements: [Gleam](https://gleam.run/) and a Docker-compatible container -runtime. - -From this directory: +Requirements: [Gleam](https://gleam.run/). ```sh -gleam deps download gleam test -gleam dev +gleam run -m opt_in_token_dev ``` -Both commands start an isolated PostgreSQL container with Testcontainers, apply -the Factos Pog event-store migration, exercise the example, and remove the -container. No developer-managed database is required. +No database or container runtime is required. ## Package layout diff --git a/examples/opt_in_token/dev/opt_in_token_dev.gleam b/examples/opt_in_token/dev/opt_in_token_dev.gleam deleted file mode 100644 index 0c63350..0000000 --- a/examples/opt_in_token/dev/opt_in_token_dev.gleam +++ /dev/null @@ -1,474 +0,0 @@ -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.Dispatch(opt_in_token.Event), - factos_pog.Error(opt_in_token.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) - - require_domain_error( - opt_in_token.dispatch( - connection, - 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, - 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, - 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, - 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, - 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, - 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, - 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, - 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, - 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, - 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, - sign_up_id: "s5", - email_address: "race@example.com", - otp: "555555", - name: "Race Winner", - initiated_minute: 0, - ) - let concurrent_results = run_concurrent_confirmation(connection) - 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_after( - connection, - factos.AllEvents, - factos.NoPosition, - 100, - opt_in_token.decode_event, - ) - 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 require_initiation( - connection: pog.Connection, - 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, - opt_in_token.InitiateSignUp( - sign_up_id:, - email_address:, - otp:, - name:, - initiated_minute:, - ), - uuid.v4_string, - ) - Nil -} - -fn require_domain_error( - result: Result( - factos.Dispatch(opt_in_token.Event), - factos_pog.Error(opt_in_token.Error, Nil), - ), - expected expected: opt_in_token.Error, -) -> Nil { - let assert Error(factos.DomainError(actual)) = result - assert actual == expected - Nil -} - -fn assert_confirmation( - dispatch: factos.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.payload - == opt_in_token.SignUpConfirmed(email_address:, otp:, name:) - Nil -} - -fn run_concurrent_confirmation( - connection: pog.Connection, -) -> List( - Result( - factos.Dispatch(opt_in_token.Event), - factos_pog.Error(opt_in_token.Error, Nil), - ), -) { - let messages = process.new_subject() - start_worker( - connection, - messages:, - worker: "first", - confirmation_id: "race-a", - ) - start_worker( - connection, - 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, - 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, - 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.Dispatch(opt_in_token.Event), - factos_pog.Error(opt_in_token.Error, Nil), -) { - let assert Ok(message) = process.receive(messages, within: 10_000) - let assert WorkerFinished(worker: _, result:) = message - result -} - -fn is_accepted( - result: Result( - factos.Dispatch(opt_in_token.Event), - factos_pog.Error(opt_in_token.Error, Nil), - ), -) -> Bool { - case result { - Ok(_) -> True - Error(_) -> False - } -} - -fn is_already_used( - result: Result( - factos.Dispatch(opt_in_token.Event), - factos_pog.Error(opt_in_token.Error, Nil), - ), -) -> Bool { - case result { - Error(factos.DomainError(opt_in_token.OtpAlreadyUsed)) -> True - Ok(_) | Error(_) -> False - } -} - -fn is_initiated(recorded: factos.Recorded(opt_in_token.Event)) -> Bool { - case recorded.event.payload { - 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.payload { - 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 <> "/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/opt_in_token/gleam.toml b/examples/opt_in_token/gleam.toml index a349884..dcf5885 100644 --- a/examples/opt_in_token/gleam.toml +++ b/examples/opt_in_token/gleam.toml @@ -3,16 +3,8 @@ 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 index 163c8b0..cc13d88 100644 --- a/examples/opt_in_token/manifest.toml +++ b/examples/opt_in_token/manifest.toml @@ -7,40 +7,14 @@ # 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 = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], source = "local", path = "../.." }, - { name = "factos_pog", version = "2.0.0", build_tools = ["gleam"], requirements = ["exception", "factos", "gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib", "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 = "factos", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], source = "local", path = "../.." }, { 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.3.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "DE4CA6850842F0266EE95317A25DD6A0A0F20CDFAB7C0ADC2E63251D7C3C72EC" }, { 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.10.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "56539216E4C4B1748714652AB38F0BD16B9101F61DB62769FDC7CD42A8E5E833" }, { 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 index 1038020..afda341 100644 --- a/examples/opt_in_token/src/opt_in_token.gleam +++ b/examples/opt_in_token/src/opt_in_token.gleam @@ -2,17 +2,14 @@ //// Boundaries. //// //// This implements the example at -//// https://dcb.events/examples/opt-in-token/ using Factos and PostgreSQL. +//// https://dcb.events/examples/opt-in-token/ using Factos simulation. //// 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/dynamic/decode import gleam/int import gleam/json -import gleam/result -import pog const otp_validity_minutes = 60 @@ -50,17 +47,17 @@ pub type Error { OtpExpired } -type OtpStatus { +pub type OtpStatus { OtpUnused OtpUsed } -type ConfirmationState { +pub type ConfirmationState { NoPending PendingSignUp(name: String, initiated_minute: Int, status: OtpStatus) } -type State { +pub type State { InitiatingSignUp ConfirmingSignUp(confirmation: ConfirmationState) } @@ -175,7 +172,7 @@ fn encode_event(event: Event) -> factos.Event(json.Json) { SignUpInitiated(email_address:, otp:, name:, initiated_minute:) -> proposed_event( type_: "SignUpInitiated", - data: sign_up_data(email_address, otp, name), + data: initiated_sign_up_data(email_address, otp, name, initiated_minute), tags: sign_up_tags(email_address, otp), ) |> factos.with_metadata( @@ -192,6 +189,20 @@ fn encode_event(event: Event) -> factos.Event(json.Json) { } } +fn initiated_sign_up_data( + email_address: String, + otp: String, + name: String, + initiated_minute: Int, +) -> json.Json { + json.object([ + #("email_address", json.string(email_address)), + #("otp", json.string(otp)), + #("name", json.string(name)), + #("initiated_minute", json.int(initiated_minute)), + ]) +} + fn sign_up_data(email_address: String, otp: String, name: String) -> json.Json { json.object([ #("email_address", json.string(email_address)), @@ -202,8 +213,8 @@ fn sign_up_data(email_address: String, otp: String, name: String) -> json.Json { fn sign_up_tags(email_address: String, otp: String) -> List(factos.Tag) { [ - factos.tag("email:" <> email_address), - factos.tag("otp:" <> otp), + factos.Tag("email:" <> email_address), + factos.Tag("otp:" <> otp), ] } @@ -212,68 +223,37 @@ fn proposed_event( data data: json.Json, tags tags: List(factos.Tag), ) -> factos.Event(json.Json) { - factos.new_event(type_: factos.event_type(type_name), version: 1, data:) + factos.event(type_: factos.EventType(type_name), version: 1, data:) |> factos.with_tags(tags:) } pub fn decode_event( - stored: factos.Recorded(String), -) -> Result(Event, factos.Recorded(String)) { - case - factos.event_type_to_string(stored.event.descriptor.type_), - stored.event.descriptor.version - { - "SignUpInitiated", 1 -> { - use initiated_minute <- result.try( - decode_initiated_minute(stored.event.descriptor.metadata) - |> result.replace_error(stored), - ) - json.parse( - stored.event.payload, - using: sign_up_decoder() - |> decode.map(fn(data) { - SignUpInitiated( - email_address: data.0, - otp: data.1, - name: data.2, - initiated_minute:, - ) - }), - ) - |> result.replace_error(stored) - } + type_: factos.EventType, + version: Int, +) -> Result(decode.Decoder(Event), Nil) { + let factos.EventType(name) = type_ + case name, version { + "SignUpInitiated", 1 -> Ok(sign_up_initiated_decoder()) "SignUpConfirmed", 1 -> - json.parse( - stored.event.payload, - using: sign_up_decoder() - |> decode.map(fn(data) { - SignUpConfirmed(email_address: data.0, otp: data.1, name: data.2) - }), + Ok( + sign_up_decoder() + |> decode.map(fn(data) { + SignUpConfirmed(email_address: data.0, otp: data.1, name: data.2) + }), ) - |> result.replace_error(stored) - _, _ -> Error(stored) + _, _ -> Error(Nil) } } -fn decode_initiated_minute( - metadata: factos.Metadata, -) -> Result(Int, json.DecodeError) { - use value <- result.try( - factos.metadata_get(metadata, initiated_minute_key) - |> result.map_error(fn(_) { metadata_int_decode_error("missing") }), - ) - int.parse(value) - |> result.map_error(fn(_) { metadata_int_decode_error(value) }) -} - -fn metadata_int_decode_error(found: String) -> json.DecodeError { - json.UnableToDecode([ - decode.DecodeError( - expected: initiated_minute_key <> " metadata containing an integer", - found:, - path: [], - ), - ]) +fn sign_up_initiated_decoder() -> decode.Decoder(Event) { + use data <- decode.then(sign_up_decoder()) + use initiated_minute <- decode.field("initiated_minute", decode.int) + decode.success(SignUpInitiated( + email_address: data.0, + otp: data.1, + name: data.2, + initiated_minute:, + )) } fn sign_up_decoder() -> decode.Decoder(#(String, String, String)) { @@ -283,22 +263,17 @@ fn sign_up_decoder() -> decode.Decoder(#(String, String, String)) { decode.success(#(email_address, otp, name)) } -pub fn dispatch( - connection: pog.Connection, - command: Command, - event_id: fn() -> String, -) -> Result(factos.Dispatch(Event), factos_pog.Error(Error, Nil)) { - factos.new_dispatch( - connection:, - decider: factos.decider(initial: initial(command), decide:, evolve:), - decision_context: decision_context(command), +pub fn model() { + factos.model( + decider: fn(command) { + factos.decider(initial: initial(command), decide:, evolve:) + }, encode: encode_event, decode: decode_event, ) - |> factos_pog.dispatch(command, event_id:) } -fn decision_context(command: Command) -> factos.DecisionContext { +pub fn decision_context(command: Command) -> factos.DecisionContext { let #(email_address, otp) = case command { InitiateSignUp( sign_up_id: _, @@ -313,14 +288,14 @@ fn decision_context(command: Command) -> factos.DecisionContext { ) } factos.Matching(items: [ - factos.item( + factos.Item( types: [ - factos.event_type("SignUpInitiated"), - factos.event_type("SignUpConfirmed"), + factos.EventType("SignUpInitiated"), + factos.EventType("SignUpConfirmed"), ], tags: [ - factos.tag("email:" <> email_address), - factos.tag("otp:" <> otp), + factos.Tag("email:" <> email_address), + factos.Tag("otp:" <> otp), ], ), ]) diff --git a/examples/opt_in_token/test/opt_in_token_test.gleam b/examples/opt_in_token/test/opt_in_token_test.gleam index 84aadfc..39a96b2 100644 --- a/examples/opt_in_token/test/opt_in_token_test.gleam +++ b/examples/opt_in_token/test/opt_in_token_test.gleam @@ -1,25 +1,101 @@ +import factos +import factos/simulate import gleeunit -import opt_in_token_dev +import opt_in_token -pub type Timeout(a) { - Timeout(time: Int, function: fn() -> a) +pub fn main() { + gleeunit.main() } -pub fn main() -> Nil { - gleeunit.main() +pub fn valid_token_confirms_signup_test() { + let initiate = + opt_in_token.InitiateSignUp( + sign_up_id: "signup-1", + email_address: "renata@example.com", + otp: "123456", + name: "Renata", + initiated_minute: 10, + ) + let confirm = + opt_in_token.ConfirmSignUp( + confirmation_id: "confirmation-1", + email_address: "renata@example.com", + otp: "123456", + current_minute: 20, + ) + + simulate.new(opt_in_token.model()) + |> simulate.dispatch( + decision_context: opt_in_token.decision_context(initiate), + command: initiate, + ) + |> simulate.dispatch( + decision_context: opt_in_token.decision_context(confirm), + command: confirm, + ) + |> simulate.assert_events([ + opt_in_token.SignUpInitiated( + email_address: "renata@example.com", + otp: "123456", + name: "Renata", + initiated_minute: 10, + ), + opt_in_token.SignUpConfirmed( + email_address: "renata@example.com", + otp: "123456", + name: "Renata", + ), + ]) + |> simulate.assert_errors([]) } -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, +pub fn unknown_token_is_rejected_test() { + let confirm = + opt_in_token.ConfirmSignUp( + confirmation_id: "confirmation-1", + email_address: "renata@example.com", + otp: "wrong", + current_minute: 20, ) - Nil + + simulate.new(opt_in_token.model()) + |> simulate.dispatch( + decision_context: opt_in_token.decision_context(confirm), + command: confirm, + ) + |> simulate.assert_events([]) + |> simulate.assert_errors([ + factos.DomainError(opt_in_token.NoPendingSignUp), + ]) +} + +pub fn expired_token_is_rejected_test() { + let initiate = + opt_in_token.InitiateSignUp( + sign_up_id: "signup-1", + email_address: "renata@example.com", + otp: "123456", + name: "Renata", + initiated_minute: 10, + ) + let expired = + opt_in_token.ConfirmSignUp( + confirmation_id: "confirmation-1", + email_address: "renata@example.com", + otp: "123456", + current_minute: 100, + ) + + simulate.new(opt_in_token.model()) + |> simulate.dispatch( + decision_context: opt_in_token.decision_context(initiate), + command: initiate, + ) + |> simulate.dispatch( + decision_context: opt_in_token.decision_context(expired), + command: expired, + ) + |> simulate.assert_errors([ + factos.DomainError(opt_in_token.OtpExpired), + ]) } diff --git a/examples/performance/test/factos_pog_performance_test.gleam b/examples/performance/test/factos_pog_performance_test.gleam deleted file mode 100644 index 0b109e5..0000000 --- a/examples/performance/test/factos_pog_performance_test.gleam +++ /dev/null @@ -1,58 +0,0 @@ -import factos -import factos_pog_performance - -pub fn main() -> Nil { - benchmark_events_are_deterministic() - subject_events_are_ordered() - profiles_are_selected_explicitly() - benchmark_tags_are_stable() - percentiles_use_nearest_rank() -} - -fn benchmark_events_are_deterministic() -> Nil { - assert factos_pog_performance.benchmark_events(batch: 2, count: 5) - == [ - factos_pog_performance.UserRegistered(user_id: 2_000_000), - factos_pog_performance.EmailChanged(email: "2000001@example.com"), - factos_pog_performance.BalanceAdjusted(delta: 2_000_002), - factos_pog_performance.UserSuspended(reason: "performance benchmark"), - factos_pog_performance.UserRegistered(user_id: 2_000_004), - ] -} - -fn subject_events_are_ordered() -> Nil { - assert factos_pog_performance.subject_events("wallet-1", 7, 3) - == [ - factos_pog_performance.SubjectAdvanced("wallet-1", 7), - factos_pog_performance.SubjectAdvanced("wallet-1", 8), - factos_pog_performance.SubjectAdvanced("wallet-1", 9), - ] -} - -fn profiles_are_selected_explicitly() -> Nil { - assert factos_pog_performance.profile_from_string("smoke") - == factos_pog_performance.Smoke - assert factos_pog_performance.profile_from_string("STRESS") - == factos_pog_performance.Stress - assert factos_pog_performance.profile_from_string("explain") - == factos_pog_performance.Explain - assert factos_pog_performance.profile_from_string("unknown") - == factos_pog_performance.Standard -} - -fn benchmark_tags_are_stable() -> Nil { - assert factos_pog_performance.benchmark_tags(3) - == [ - factos.tag("benchmark:1"), - factos.tag("benchmark:2"), - factos.tag("benchmark:3"), - ] -} - -fn percentiles_use_nearest_rank() -> Nil { - let samples = [40.0, 10.0, 30.0, 20.0] - assert factos_pog_performance.percentile(samples, 0.0) == 10.0 - assert factos_pog_performance.percentile(samples, 50.0) == 20.0 - assert factos_pog_performance.percentile(samples, 95.0) == 40.0 - assert factos_pog_performance.percentile([], 99.0) == 0.0 -} diff --git a/examples/prevent_record_duplication/CHANGELOG.md b/examples/prevent_record_duplication/CHANGELOG.md new file mode 100644 index 0000000..9b4114d --- /dev/null +++ b/examples/prevent_record_duplication/CHANGELOG.md @@ -0,0 +1 @@ +# prevent_record_duplication changelog diff --git a/examples/prevent_record_duplication/README.md b/examples/prevent_record_duplication/README.md index b9296b7..9866aa1 100644 --- a/examples/prevent_record_duplication/README.md +++ b/examples/prevent_record_duplication/README.md @@ -1,6 +1,6 @@ # Prevent record duplication -A runnable Gleam, Factos, and PostgreSQL implementation of the +A runnable Gleam and Factos simulation of the [DCB record duplication example](https://dcb.events/examples/prevent-record-duplication/). ## Challenge @@ -16,12 +16,9 @@ Every `OrderPlaced` event is tagged with both `order:` and `OrderPlaced` facts carrying its idempotency tag. The folded state therefore answers one question: has this token already been used? -Factos supplies the store-independent event envelope, dispatch, and error -contract. The encoder supplies JSON values directly to Factos Pog, which reads -the decision context, runs the pure decision, and conditionally appends in a -serializable PostgreSQL transaction. -When concurrent requests use the same token, only one can append `OrderPlaced`; -the other retries against the committed fact and returns `Resubmission`. +The model exposes JSON events, the idempotency decision context, and the pure +decider to `factos/simulate`. The scenarios prove token reuse rejection; +backend concurrency remains covered by `factos_pog`. The order ID remains independent from the client-provided idempotency token. No token table, pre-issued server token, or read model is required. @@ -33,28 +30,20 @@ additional domain data. ## Run it -Requirements: [Gleam](https://gleam.run/) and a Docker-compatible container -runtime. - -From this directory: +Requirements: [Gleam](https://gleam.run/). ```sh -gleam deps download gleam test -gleam dev +gleam run -m prevent_record_duplication_dev ``` -Both commands start an isolated PostgreSQL container with Testcontainers, apply -the Factos Pog event-store migration, exercise sequential and synchronized -concurrent submissions, and remove the container. No developer-managed database -is required. +No database or container runtime is required. ## Package layout - [`src/prevent_record_duplication.gleam`](src/prevent_record_duplication.gleam) - contains the command, event, idempotency decider, DCB context, encoder, - decoder, and PostgreSQL-backed dispatch API. + contains the command, event, idempotency decider, DCB context, codec, and model. - [`test/prevent_record_duplication_test.gleam`](test/prevent_record_duplication_test.gleam) - verifies new, repeated, and concurrent token submissions. + verifies duplicate and independent token scenarios. - [`dev/prevent_record_duplication_dev.gleam`](dev/prevent_record_duplication_dev.gleam) is the runnable demonstration. diff --git a/examples/prevent_record_duplication/dev/prevent_record_duplication_dev.gleam b/examples/prevent_record_duplication/dev/prevent_record_duplication_dev.gleam index 4742672..f635f69 100644 --- a/examples/prevent_record_duplication/dev/prevent_record_duplication_dev.gleam +++ b/examples/prevent_record_duplication/dev/prevent_record_duplication_dev.gleam @@ -1,297 +1,24 @@ -import factos -import factos/factos_pog -import gleam/erlang/application -import gleam/erlang/process +import factos/simulate 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, +pub fn main() { + let command = + prevent_record_duplication.PlaceOrder( + order_id: "order-1", + idempotency_token: "token-1", + ) + simulate.new(prevent_record_duplication.model()) + |> simulate.dispatch( + decision_context: prevent_record_duplication.decision_context(command), + command:, ) -} - -type WorkerMessage { - WorkerReady(worker: String, release: process.Subject(Nil)) - WorkerFinished( - worker: String, - result: Result( - factos.Dispatch(prevent_record_duplication.Event), - factos_pog.Error(prevent_record_duplication.Error, Nil), + |> simulate.assert_events([ + prevent_record_duplication.OrderPlaced( + order_id: "order-1", + idempotency_token: "token-1", ), - ) -} - -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 assert Ok(_) = - prevent_record_duplication.dispatch( - connection, - prevent_record_duplication.PlaceOrder( - order_id: "o12345", - idempotency_token: "11111", - ), - uuid.v4_string, - ) - let assert Error(factos.DomainError(prevent_record_duplication.Resubmission)) = - prevent_record_duplication.dispatch( - connection, - prevent_record_duplication.PlaceOrder( - order_id: "o54321", - idempotency_token: "11111", - ), - uuid.v4_string, - ) - let assert Ok(_) = - prevent_record_duplication.dispatch( - connection, - prevent_record_duplication.PlaceOrder( - order_id: "o54321", - idempotency_token: "22222", - ), - uuid.v4_string, - ) - - let concurrent_results = run_concurrent_scenario(connection) - 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_after( - connection, - factos.AllEvents, - factos.NoPosition, - 10, - prevent_record_duplication.decode_event, - ) - 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 run_concurrent_scenario( - connection: pog.Connection, -) -> List( - Result( - factos.Dispatch(prevent_record_duplication.Event), - factos_pog.Error(prevent_record_duplication.Error, Nil), - ), -) { - let messages = process.new_subject() - start_worker(connection, messages:, worker: "first", order_id: "o90001") - start_worker(connection, 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, - 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, - 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.Dispatch(prevent_record_duplication.Event), - factos_pog.Error(prevent_record_duplication.Error, Nil), -) { - let assert Ok(message) = process.receive(messages, within: 10_000) - let assert WorkerFinished(worker: _, result:) = message - result -} - -fn is_accepted( - result: Result( - factos.Dispatch(prevent_record_duplication.Event), - factos_pog.Error(prevent_record_duplication.Error, Nil), - ), -) -> Bool { - case result { - Ok(_) -> True - Error(_) -> False - } -} - -fn is_resubmission( - result: Result( - factos.Dispatch(prevent_record_duplication.Event), - factos_pog.Error(prevent_record_duplication.Error, Nil), - ), -) -> Bool { - case result { - Error(factos.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.payload - 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 <> "/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]) - } + ]) + |> simulate.assert_errors([]) + io.println("record duplication scenario: accepted") } diff --git a/examples/prevent_record_duplication/gleam.toml b/examples/prevent_record_duplication/gleam.toml index 6620f00..14f8881 100644 --- a/examples/prevent_record_duplication/gleam.toml +++ b/examples/prevent_record_duplication/gleam.toml @@ -3,16 +3,8 @@ 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 index 163c8b0..cc13d88 100644 --- a/examples/prevent_record_duplication/manifest.toml +++ b/examples/prevent_record_duplication/manifest.toml @@ -7,40 +7,14 @@ # 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 = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], source = "local", path = "../.." }, - { name = "factos_pog", version = "2.0.0", build_tools = ["gleam"], requirements = ["exception", "factos", "gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib", "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 = "factos", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], source = "local", path = "../.." }, { 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.3.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "DE4CA6850842F0266EE95317A25DD6A0A0F20CDFAB7C0ADC2E63251D7C3C72EC" }, { 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.10.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "56539216E4C4B1748714652AB38F0BD16B9101F61DB62769FDC7CD42A8E5E833" }, { 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 index af5f888..b00fed3 100644 --- a/examples/prevent_record_duplication/src/prevent_record_duplication.gleam +++ b/examples/prevent_record_duplication/src/prevent_record_duplication.gleam @@ -1,15 +1,12 @@ //// Prevent record duplication with Dynamic Consistency Boundaries. //// //// This implements the DCB example at -//// https://dcb.events/examples/prevent-record-duplication/ using Factos and -//// PostgreSQL. +//// https://dcb.events/examples/prevent-record-duplication/ using Factos +//// simulation. import factos -import factos/factos_pog import gleam/dynamic/decode import gleam/json -import gleam/result -import pog pub type Command { PlaceOrder(order_id: String, idempotency_token: String) @@ -19,7 +16,7 @@ pub type Event { OrderPlaced(order_id: String, idempotency_token: String) } -type State { +pub type State { TokenUnused TokenUsed } @@ -59,24 +56,21 @@ fn encode_event(event: Event) -> factos.Event(json.Json) { #("idempotency_token", json.string(idempotency_token)), ]) - factos.new_event(type_: factos.event_type("OrderPlaced"), version: 1, data:) + factos.event(type_: factos.EventType("OrderPlaced"), version: 1, data:) |> factos.with_tags(tags: [ - factos.tag("order:" <> order_id), - factos.tag("idempotency:" <> idempotency_token), + factos.Tag("order:" <> order_id), + factos.Tag("idempotency:" <> idempotency_token), ]) } pub fn decode_event( - stored: factos.Recorded(String), -) -> Result(Event, factos.Recorded(String)) { - case - factos.event_type_to_string(stored.event.descriptor.type_), - stored.event.descriptor.version - { - "OrderPlaced", 1 -> - json.parse(stored.event.payload, using: event_decoder()) - |> result.replace_error(stored) - _, _ -> Error(stored) + type_: factos.EventType, + version: Int, +) -> Result(decode.Decoder(Event), Nil) { + let factos.EventType(name) = type_ + case name, version { + "OrderPlaced", 1 -> Ok(event_decoder()) + _, _ -> Error(Nil) } } @@ -86,26 +80,21 @@ fn event_decoder() -> decode.Decoder(Event) { decode.success(OrderPlaced(order_id:, idempotency_token:)) } -pub fn dispatch( - connection: pog.Connection, - command: Command, - event_id: fn() -> String, -) -> Result(factos.Dispatch(Event), factos_pog.Error(Error, Nil)) { - factos.new_dispatch( - connection:, - decider: factos.decider(initial: initial(command), decide:, evolve:), - decision_context: decision_context(command), +pub fn model() { + factos.model( + decider: fn(command) { + factos.decider(initial: initial(command), decide:, evolve:) + }, encode: encode_event, decode: decode_event, ) - |> factos_pog.dispatch(command, event_id:) } -fn decision_context(command: Command) -> factos.DecisionContext { +pub fn decision_context(command: Command) -> factos.DecisionContext { let PlaceOrder(order_id: _, idempotency_token:) = command factos.Matching([ - factos.item(types: [factos.event_type("OrderPlaced")], tags: [ - factos.tag("idempotency:" <> idempotency_token), + factos.Item(types: [factos.EventType("OrderPlaced")], tags: [ + factos.Tag("idempotency:" <> idempotency_token), ]), ]) } diff --git a/examples/prevent_record_duplication/test/prevent_record_duplication_test.gleam b/examples/prevent_record_duplication/test/prevent_record_duplication_test.gleam index 4ceab00..92b4d56 100644 --- a/examples/prevent_record_duplication/test/prevent_record_duplication_test.gleam +++ b/examples/prevent_record_duplication/test/prevent_record_duplication_test.gleam @@ -1,24 +1,68 @@ +import factos +import factos/simulate import gleeunit -import prevent_record_duplication_dev +import prevent_record_duplication -pub type Timeout(a) { - Timeout(time: Int, function: fn() -> a) +pub fn main() { + gleeunit.main() } -pub fn main() -> Nil { - gleeunit.main() +pub fn duplicate_token_is_rejected_test() { + let first = + prevent_record_duplication.PlaceOrder( + order_id: "order-1", + idempotency_token: "token-1", + ) + let duplicate = + prevent_record_duplication.PlaceOrder( + order_id: "order-2", + idempotency_token: "token-1", + ) + + simulate.new(prevent_record_duplication.model()) + |> simulate.dispatch( + decision_context: prevent_record_duplication.decision_context(first), + command: first, + ) + |> simulate.dispatch( + decision_context: prevent_record_duplication.decision_context(duplicate), + command: duplicate, + ) + |> simulate.assert_events([ + prevent_record_duplication.OrderPlaced( + order_id: "order-1", + idempotency_token: "token-1", + ), + ]) + |> simulate.assert_errors([ + factos.DomainError(prevent_record_duplication.Resubmission), + ]) } -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, +pub fn different_tokens_are_independent_test() { + let first = + prevent_record_duplication.PlaceOrder( + order_id: "order-1", + idempotency_token: "token-1", + ) + let second = + prevent_record_duplication.PlaceOrder( + order_id: "order-2", + idempotency_token: "token-2", ) - Nil + + simulate.new(prevent_record_duplication.model()) + |> simulate.dispatch( + decision_context: prevent_record_duplication.decision_context(first), + command: first, + ) + |> simulate.dispatch( + decision_context: prevent_record_duplication.decision_context(second), + command: second, + ) + |> simulate.assert_events([ + prevent_record_duplication.OrderPlaced("order-1", "token-1"), + prevent_record_duplication.OrderPlaced("order-2", "token-2"), + ]) + |> simulate.assert_errors([]) } diff --git a/examples/unique_username/CHANGELOG.md b/examples/unique_username/CHANGELOG.md new file mode 100644 index 0000000..2b26c25 --- /dev/null +++ b/examples/unique_username/CHANGELOG.md @@ -0,0 +1 @@ +# unique_username changelog diff --git a/examples/unique_username/README.md b/examples/unique_username/README.md index 249753b..466b10f 100644 --- a/examples/unique_username/README.md +++ b/examples/unique_username/README.md @@ -1,6 +1,6 @@ # Unique username -A runnable Gleam, Factos, and PostgreSQL implementation of the +A runnable Gleam and Factos simulation of the [DCB unique username example](https://dcb.events/examples/unique-username/). ## Challenge @@ -15,12 +15,9 @@ Every fact that affects a claim is tagged with the username: `AccountRegistered` and `AccountClosed` carry one `username:` tag, while `UsernameChanged` carries tags for both the old and new values. -Registration selects the complete history for the requested tag and folds it -into `Available`, `Claimed`, or `RetainedUntil`. The shared Factos dispatch -builder describes that decision; the `factos_pog` backend reads the context and -conditionally appends in a serializable PostgreSQL transaction. Concurrent -registrations for one username therefore produce one accepted registration and -one `UsernameClaimed` result. +The model exposes its codec, command-specific decider, and decision contexts to +`factos/simulate`. Scenarios prove claim rejection and retention without a +database; backend concurrency remains covered by `factos_pog`. The package demonstrates: @@ -31,16 +28,15 @@ The package demonstrates: - three-day retention for closed or changed usernames; - JSON event payloads with claim tags and recorded-day metadata. -The encoder produces `factos.Event(json.Json)` envelopes for PostgreSQL JSONB -storage. The decoder receives `factos.Recorded(String)`, validates the nested -event descriptor and payload, and recovers `recorded_day` from metadata for -closures and username changes. +The encoder produces `factos.Event(json.Json)` envelopes. Versioned payloads +carry `recorded_day` where required to rebuild decision state, while metadata +remains available for operational inspection. The source example uses relative `daysAgo` metadata for illustration. This implementation stores an absolute `recorded_day` and supplies `current_day` in -the registration command, keeping the decider deterministic across serializable -retries. Missing or invalid recorded-day metadata, malformed JSON, and unsupported -event versions reject the stored record during row decoding. +the registration command, keeping retries deterministic. Missing or invalid +recorded-day payloads, malformed JSON, and unsupported event versions reject the +stored record during row decoding. The example deliberately tags the raw username. A production system should normalize usernames before deciding uniqueness and may hash tag values when the @@ -48,20 +44,14 @@ event store's tag index should not expose them. ## Run it -Requirements: [Gleam](https://gleam.run/) and a Docker-compatible container -runtime. - -From this directory: +Requirements: [Gleam](https://gleam.run/). ```sh -gleam deps download gleam test -gleam dev +gleam run -m unique_username_dev ``` -Both commands start an isolated PostgreSQL container with Testcontainers, apply -the Factos Pog event-store migration, exercise the example, and remove the -container. No developer-managed database is required. +No database or container runtime is required. ## Package layout diff --git a/examples/unique_username/dev/unique_username_dev.gleam b/examples/unique_username/dev/unique_username_dev.gleam index 1d09f3c..794acc6 100644 --- a/examples/unique_username/dev/unique_username_dev.gleam +++ b/examples/unique_username/dev/unique_username_dev.gleam @@ -1,429 +1,22 @@ -import factos -import factos/factos_pog -import gleam/erlang/application -import gleam/erlang/process +import factos/simulate 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.Dispatch(unique_username.Event), - factos_pog.Error(unique_username.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) - - require_registration( - connection, - account_id: "a1", - username: "u1", - current_day: 0, - ) - require_claimed( - unique_username.dispatch( - connection, - unique_username.RegisterAccount( - account_id: "a2", - username: "u1", - current_day: 0, - ), - uuid.v4_string, - ), - username: "u1", - ) - let assert Ok(_) = - unique_username.dispatch( - connection, - unique_username.RecordAccountClosed( - account_id: "a1", - username: "u1", - recorded_day: 0, - ), - uuid.v4_string, - ) - require_claimed( - unique_username.dispatch( - connection, - unique_username.RegisterAccount( - account_id: "a2", - username: "u1", - current_day: 3, - ), - uuid.v4_string, - ), - username: "u1", - ) - require_registration( - connection, - account_id: "a2", - username: "u1", - current_day: 4, - ) - - require_registration( - connection, - account_id: "a3", - username: "u2", - current_day: 0, - ) - let assert Ok(_) = - unique_username.dispatch( - connection, - unique_username.RecordUsernameChanged( - account_id: "a3", - old_username: "u2", - new_username: "u3", - recorded_day: 0, - ), - uuid.v4_string, +pub fn main() { + let command = + unique_username.RegisterAccount( + account_id: "account-1", + username: "renata", + current_day: 0, ) - require_claimed( - unique_username.dispatch( - connection, - unique_username.RegisterAccount( - account_id: "a4", - username: "u2", - current_day: 3, - ), - uuid.v4_string, - ), - username: "u2", - ) - require_registration( - connection, - account_id: "a4", - username: "u2", - current_day: 4, - ) - require_claimed( - unique_username.dispatch( - connection, - unique_username.RegisterAccount( - account_id: "a5", - username: "u3", - current_day: 4, - ), - uuid.v4_string, - ), - username: "u3", - ) - - let concurrent_results = run_concurrent_claim(connection) - 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_after( - connection, - factos.AllEvents, - factos.NoPosition, - 100, - unique_username.decode_event, - ) - 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 require_registration( - connection: pog.Connection, - account_id account_id: String, - username username: String, - current_day current_day: Int, -) -> Nil { - let assert Ok(_) = - unique_username.dispatch( - connection, - unique_username.RegisterAccount(account_id:, username:, current_day:), - uuid.v4_string, - ) - Nil -} - -fn require_claimed( - result: Result( - factos.Dispatch(unique_username.Event), - factos_pog.Error(unique_username.Error, Nil), - ), - username username: String, -) -> Nil { - let assert Error(factos.DomainError(unique_username.UsernameClaimed( - username: claimed_username, - ))) = result - assert claimed_username == username - Nil -} - -fn run_concurrent_claim( - connection: pog.Connection, -) -> List( - Result( - factos.Dispatch(unique_username.Event), - factos_pog.Error(unique_username.Error, Nil), - ), -) { - let messages = process.new_subject() - start_worker(connection, messages:, worker: "first", account_id: "race-a") - start_worker(connection, 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, - 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, - 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.Dispatch(unique_username.Event), - factos_pog.Error(unique_username.Error, Nil), -) { - let assert Ok(message) = process.receive(messages, within: 10_000) - let assert WorkerFinished(worker: _, result:) = message - result -} - -fn is_accepted( - result: Result( - factos.Dispatch(unique_username.Event), - factos_pog.Error(unique_username.Error, Nil), - ), -) -> Bool { - case result { - Ok(_) -> True - Error(_) -> False - } -} - -fn is_claimed( - result: Result( - factos.Dispatch(unique_username.Event), - factos_pog.Error(unique_username.Error, Nil), - ), -) -> Bool { - case result { - Error(factos.DomainError(unique_username.UsernameClaimed(username: "raced"))) -> - True - Ok(_) | Error(_) -> False - } -} - -fn is_registration(recorded: factos.Recorded(unique_username.Event)) -> Bool { - case recorded.event.payload { - 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.payload { - 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.payload { - 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 <> "/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]) - } + simulate.new(unique_username.model()) + |> simulate.dispatch( + decision_context: unique_username.decision_context(command), + command:, + ) + |> simulate.assert_events([ + unique_username.AccountRegistered(username: "renata"), + ]) + |> simulate.assert_errors([]) + io.println("unique username scenario: accepted") } diff --git a/examples/unique_username/gleam.toml b/examples/unique_username/gleam.toml index e408f08..99915f0 100644 --- a/examples/unique_username/gleam.toml +++ b/examples/unique_username/gleam.toml @@ -3,16 +3,8 @@ 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 index 163c8b0..cc13d88 100644 --- a/examples/unique_username/manifest.toml +++ b/examples/unique_username/manifest.toml @@ -7,40 +7,14 @@ # 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 = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], source = "local", path = "../.." }, - { name = "factos_pog", version = "2.0.0", build_tools = ["gleam"], requirements = ["exception", "factos", "gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib", "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 = "factos", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], source = "local", path = "../.." }, { 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.3.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "DE4CA6850842F0266EE95317A25DD6A0A0F20CDFAB7C0ADC2E63251D7C3C72EC" }, { 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.10.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "56539216E4C4B1748714652AB38F0BD16B9101F61DB62769FDC7CD42A8E5E833" }, { 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 index eaa5bdb..8697af8 100644 --- a/examples/unique_username/src/unique_username.gleam +++ b/examples/unique_username/src/unique_username.gleam @@ -1,17 +1,14 @@ //// Enforce globally unique usernames with Dynamic Consistency Boundaries. //// //// This implements the example at -//// https://dcb.events/examples/unique-username/ using Factos and PostgreSQL. +//// https://dcb.events/examples/unique-username/ using Factos simulation. //// 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/dynamic/decode import gleam/int import gleam/json -import gleam/result -import pog const username_retention_days = 3 @@ -38,13 +35,13 @@ pub type Error { UsernameClaimed(username: String) } -type ClaimState { +pub type ClaimState { Available Claimed RetainedUntil(day: Int) } -type State { +pub type State { RegisteringAccount(username: String, claim: ClaimState) RecordingFact } @@ -138,13 +135,16 @@ fn encode_event(event: Event) -> factos.Event(json.Json) { proposed_event( type_: "AccountRegistered", data: json.object([#("username", json.string(username))]), - tags: [factos.tag("username:" <> 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)], + data: json.object([ + #("username", json.string(username)), + #("recorded_day", json.int(recorded_day)), + ]), + tags: [factos.Tag("username:" <> username)], ) |> factos.with_metadata(metadata: recorded_day_metadata(recorded_day)) UsernameChanged(old_username:, new_username:, recorded_day:) -> @@ -153,10 +153,11 @@ fn encode_event(event: Event) -> factos.Event(json.Json) { data: json.object([ #("old_username", json.string(old_username)), #("new_username", json.string(new_username)), + #("recorded_day", json.int(recorded_day)), ]), tags: [ - factos.tag("username:" <> old_username), - factos.tag("username:" <> new_username), + factos.Tag("username:" <> old_username), + factos.Tag("username:" <> new_username), ], ) |> factos.with_metadata(metadata: recorded_day_metadata(recorded_day)) @@ -168,7 +169,7 @@ fn proposed_event( data data: json.Json, tags tags: List(factos.Tag), ) -> factos.Event(json.Json) { - factos.new_event(type_: factos.event_type(type_name), version: 1, data:) + factos.event(type_: factos.EventType(type_name), version: 1, data:) |> factos.with_tags(tags:) } @@ -177,112 +178,58 @@ fn recorded_day_metadata(recorded_day: Int) -> factos.Metadata { } pub fn decode_event( - stored: factos.Recorded(String), -) -> Result(Event, factos.Recorded(String)) { - case - factos.event_type_to_string(stored.event.descriptor.type_), - stored.event.descriptor.version - { - "AccountRegistered", 1 -> - json.parse( - stored.event.payload, - using: username_decoder() - |> decode.map(fn(username) { AccountRegistered(username:) }), - ) - |> result.replace_error(stored) - "AccountClosed", 1 -> { - use recorded_day <- result.try( - decode_recorded_day(stored.event.descriptor.metadata) - |> result.replace_error(stored), - ) - json.parse( - stored.event.payload, - using: username_decoder() - |> decode.map(fn(username) { AccountClosed(username:, recorded_day:) }), - ) - |> result.replace_error(stored) - } - "UsernameChanged", 1 -> { - use recorded_day <- result.try( - decode_recorded_day(stored.event.descriptor.metadata) - |> result.replace_error(stored), - ) - json.parse( - stored.event.payload, - using: changed_names_decoder() - |> decode.map(fn(names) { - UsernameChanged( - old_username: names.0, - new_username: names.1, - recorded_day:, - ) - }), - ) - |> result.replace_error(stored) - } - _, _ -> Error(stored) + type_: factos.EventType, + version: Int, +) -> Result(decode.Decoder(Event), Nil) { + let factos.EventType(name) = type_ + case name, version { + "AccountRegistered", 1 -> Ok(username_decoder()) + "AccountClosed", 1 -> Ok(account_closed_decoder()) + "UsernameChanged", 1 -> Ok(username_changed_decoder()) + _, _ -> Error(Nil) } } -fn decode_recorded_day( - metadata: factos.Metadata, -) -> Result(Int, json.DecodeError) { - use value <- result.try( - factos.metadata_get(metadata, recorded_day_key) - |> result.map_error(fn(_) { metadata_int_decode_error("missing") }), - ) - int.parse(value) - |> result.map_error(fn(_) { metadata_int_decode_error(value) }) -} - -fn metadata_int_decode_error(found: String) -> json.DecodeError { - json.UnableToDecode([ - decode.DecodeError( - expected: recorded_day_key <> " metadata containing an integer", - found:, - path: [], - ), - ]) +fn username_decoder() -> decode.Decoder(Event) { + use username <- decode.field("username", decode.string) + decode.success(AccountRegistered(username)) } -fn username_decoder() -> decode.Decoder(String) { +fn account_closed_decoder() -> decode.Decoder(Event) { use username <- decode.field("username", decode.string) - decode.success(username) + use recorded_day <- decode.field("recorded_day", decode.int) + decode.success(AccountClosed(username:, recorded_day:)) } -fn changed_names_decoder() -> decode.Decoder(#(String, String)) { +fn username_changed_decoder() -> decode.Decoder(Event) { use old_username <- decode.field("old_username", decode.string) use new_username <- decode.field("new_username", decode.string) - decode.success(#(old_username, new_username)) + use recorded_day <- decode.field("recorded_day", decode.int) + decode.success(UsernameChanged(old_username:, new_username:, recorded_day:)) } -pub fn dispatch( - connection: pog.Connection, - command: Command, - event_id: fn() -> String, -) -> Result(factos.Dispatch(Event), factos_pog.Error(Error, Nil)) { - factos.new_dispatch( - connection:, - decider: factos.decider(initial: initial(command), decide:, evolve:), - decision_context: query(command), +pub fn model() { + factos.model( + decider: fn(command) { + factos.decider(initial: initial(command), decide:, evolve:) + }, encode: encode_event, decode: decode_event, ) - |> factos_pog.dispatch(command, event_id:) } -fn query(command: Command) -> factos.DecisionContext { +pub fn decision_context(command: Command) -> factos.DecisionContext { case command { RegisterAccount(account_id: _, username:, current_day: _) | RecordAccountClosed(account_id: _, username:, recorded_day: _) -> factos.Matching(items: [ - factos.item( + factos.Item( types: [ - factos.event_type("AccountRegistered"), - factos.event_type("AccountClosed"), - factos.event_type("UsernameChanged"), + factos.EventType("AccountRegistered"), + factos.EventType("AccountClosed"), + factos.EventType("UsernameChanged"), ], - tags: [factos.tag("username:" <> username)], + tags: [factos.Tag("username:" <> username)], ), ]) RecordUsernameChanged( @@ -292,21 +239,21 @@ fn query(command: Command) -> factos.DecisionContext { recorded_day: _, ) -> factos.Matching(items: [ - factos.item( + factos.Item( types: [ - factos.event_type("AccountRegistered"), - factos.event_type("AccountClosed"), - factos.event_type("UsernameChanged"), + factos.EventType("AccountRegistered"), + factos.EventType("AccountClosed"), + factos.EventType("UsernameChanged"), ], - tags: [factos.tag("username:" <> old_username)], + tags: [factos.Tag("username:" <> old_username)], ), - factos.item( + factos.Item( types: [ - factos.event_type("AccountRegistered"), - factos.event_type("AccountClosed"), - factos.event_type("UsernameChanged"), + factos.EventType("AccountRegistered"), + factos.EventType("AccountClosed"), + factos.EventType("UsernameChanged"), ], - tags: [factos.tag("username:" <> new_username)], + tags: [factos.Tag("username:" <> new_username)], ), ]) } diff --git a/examples/unique_username/test/unique_username_test.gleam b/examples/unique_username/test/unique_username_test.gleam index 6a5eab5..ac27300 100644 --- a/examples/unique_username/test/unique_username_test.gleam +++ b/examples/unique_username/test/unique_username_test.gleam @@ -1,26 +1,92 @@ +import factos +import factos/simulate import gleeunit -import unique_username_dev +import unique_username -pub type Timeout(a) { - Timeout(time: Int, function: fn() -> a) +pub fn main() { + gleeunit.main() } -pub fn main() -> Nil { - gleeunit.main() +pub fn duplicate_username_is_rejected_test() { + let first = + unique_username.RegisterAccount( + account_id: "account-1", + username: "renata", + current_day: 0, + ) + let duplicate = + unique_username.RegisterAccount( + account_id: "account-2", + username: "renata", + current_day: 0, + ) + + simulate.new(unique_username.model()) + |> simulate.dispatch( + decision_context: unique_username.decision_context(first), + command: first, + ) + |> simulate.dispatch( + decision_context: unique_username.decision_context(duplicate), + command: duplicate, + ) + |> simulate.assert_events([ + unique_username.AccountRegistered(username: "renata"), + ]) + |> simulate.assert_errors([ + factos.DomainError(unique_username.UsernameClaimed(username: "renata")), + ]) } -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, +pub fn closed_username_is_released_after_retention_test() { + let register = + unique_username.RegisterAccount( + account_id: "account-1", + username: "renata", + current_day: 0, ) - Nil + let close = + unique_username.RecordAccountClosed( + account_id: "account-1", + username: "renata", + recorded_day: 1, + ) + let retained = + unique_username.RegisterAccount( + account_id: "account-2", + username: "renata", + current_day: 3, + ) + let released = + unique_username.RegisterAccount( + account_id: "account-2", + username: "renata", + current_day: 5, + ) + + simulate.new(unique_username.model()) + |> simulate.dispatch( + decision_context: unique_username.decision_context(register), + command: register, + ) + |> simulate.dispatch( + decision_context: unique_username.decision_context(close), + command: close, + ) + |> simulate.dispatch( + decision_context: unique_username.decision_context(retained), + command: retained, + ) + |> simulate.dispatch( + decision_context: unique_username.decision_context(released), + command: released, + ) + |> simulate.assert_events([ + unique_username.AccountRegistered(username: "renata"), + unique_username.AccountClosed(username: "renata", recorded_day: 1), + unique_username.AccountRegistered(username: "renata"), + ]) + |> simulate.assert_errors([ + factos.DomainError(unique_username.UsernameClaimed(username: "renata")), + ]) } diff --git a/gleam.toml b/gleam.toml index 8eae5b1..058062f 100644 --- a/gleam.toml +++ b/gleam.toml @@ -38,6 +38,7 @@ source = "./docs/domain-driven-design.md" [dependencies] gleam_stdlib = ">= 1.0.0 and < 2.0.0" +gleam_json = ">= 3.1.0 and < 4.0.0" [dev_dependencies] gleeunit = ">= 1.0.0 and < 2.0.0" diff --git a/manifest.toml b/manifest.toml index b93d518..c9bc7b3 100644 --- a/manifest.toml +++ b/manifest.toml @@ -7,10 +7,12 @@ # You should check this file into your source control repository. packages = [ + { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, { name = "gleam_stdlib", version = "1.0.5", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "CEE5B6C076A85B45F60C585F4316C63EC8B7127C119D5738C3958A9C4D50404E" }, { name = "gleeunit", version = "1.11.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "EC31ABA74256AEA531EDF8169931D775BBB384FED0A8A1BDC4DD9354E3E21826" }, ] [requirements] +gleam_json = { version = ">= 3.1.0 and < 4.0.0" } gleam_stdlib = { version = ">= 1.0.0 and < 2.0.0" } gleeunit = { version = ">= 1.0.0 and < 2.0.0" } diff --git a/src/factos.gleam b/src/factos.gleam index 17644fe..52268b0 100644 --- a/src/factos.gleam +++ b/src/factos.gleam @@ -21,23 +21,28 @@ //// Backends implement the storage-specific parts of that flow. This module keeps //// the shared types and pure computations small and portable. -import gleam/int +import gleam/dynamic/decode +import gleam/json import gleam/list -import gleam/result -pub type Subscription(event, subscription_error, connection) { +/// Extend a backend transaction for one event accepted by a dispatch. +/// +/// The transaction type is backend-defined. Interactive backends can execute +/// immediately and return the same transaction connection. Planning backends +/// can return an immutable transaction plan containing additional mutations. +pub type Subscription(delivery, subscription_error, transaction) { Subscription( - consistency: SubscriptionConsistency, - handle: fn(connection, Recorded(event)) -> Result(Nil, subscription_error), + apply: fn(transaction, delivery) -> Result(transaction, subscription_error), ) } +/// A domain, subscription, storage, or codec failure produced by dispatch. pub type Error(domain_error, subscription_error, store_error, decode_error) { /// The decider rejected the command with a domain error. DomainError(domain_error) - /// A strong subscription callback rejected an event. - SubscriptionError(error: subscription_error) + /// A strong subscription could not extend the dispatch transaction. + SubscriptionError(subscription_error) /// The backend store returned an error. StoreError(store_error) @@ -47,133 +52,56 @@ pub type Error(domain_error, subscription_error, store_error, decode_error) { /// A stored event could not be decoded by the application codec. DecodeError(decode_error) -} -pub type SubscriptionConsistency { - /// Start one asynchronous worker after a successful final commit. - FireAndForget - /// Run the callback for each accepted event inside the dispatch transaction. - StrongConsistency + /// No decoder exists for the stored event type and version. + InvalidSchema(EventType, Int) } -/// Configure a dispatch-bound subscription. +/// Configure work that shares the backend append transaction. /// -/// The callback receives every event accepted by the dispatch carrying this -/// subscription. Strong callbacks share the dispatch transaction. -/// Fire-and-forget callbacks start asynchronously only after a successful -/// commit. -pub fn new_subscription( - consistency consistency: SubscriptionConsistency, - handle handle: fn(connection, Recorded(event)) -> - Result(Nil, subscription_error), -) -> Subscription(event, subscription_error, connection) { - Subscription(consistency:, handle:) -} - -pub type DispatchBuilder( - command, - state, - event, - encoded_event, - recorded_event, - domain_error, - subscription_error, - connection, - decode_error, -) { - DispatchBuilder( - connection: connection, - decision_context: DecisionContext, - decider: Decider(command, state, event, domain_error), - encode: fn(event) -> Event(encoded_event), - decode: fn(Recorded(recorded_event)) -> Result(event, decode_error), - retry_attempts: Int, - subscriptions: List(Subscription(event, subscription_error, connection)), - ) -} - -pub fn new_dispatch( - connection connection: connection, - decision_context decision_context: DecisionContext, - decider decider: Decider(command, state, event, domain_error), - encode encode: fn(event) -> Event(encoded_event), - decode decode: fn(Recorded(recorded_event)) -> Result(event, decode_error), -) -> DispatchBuilder( - command, - state, - event, - encoded_event, - recorded_event, - domain_error, - subscription_error, - connection, - decode_error, -) { - DispatchBuilder( - connection:, - decision_context:, - decider:, - encode:, - decode:, - retry_attempts: 5, - subscriptions: [], +/// The callback returns the updated transaction value. Returning `Error` +/// rejects the subscription and rolls back the originating append. +/// +/// ```gleam +/// let projection = +/// factos.subscription(apply: fn(transaction, recorded) { +/// Ok(projection.insert(transaction, recorded.event.payload)) +/// }) +/// ``` +pub fn subscription( + apply apply: fn(transaction, delivery) -> + Result(transaction, subscription_error), +) -> Subscription(delivery, subscription_error, transaction) { + Subscription(apply:) +} + +/// Store-independent model configuration shared by backends and simulations. +/// +/// A model owns no connection or retry policy. +pub type Model(command, state, event, domain_error) { + Model( + decider: fn(command) -> Decider(command, state, event, domain_error), + encode: fn(event) -> Event(json.Json), + decode: fn(EventType, Int) -> Result(decode.Decoder(event), Nil), ) } -pub fn with_subscriptions( - builder: DispatchBuilder( - command, - state, - event, - encoded_event, - recorded_event, - domain_error, - subscription_error, - connection, - decode_error, - ), - subscriptions subscriptions: List( - Subscription(event, subscription_error, connection), - ), -) -> DispatchBuilder( - command, - state, - event, - encoded_event, - recorded_event, - domain_error, - subscription_error, - connection, - decode_error, -) { - DispatchBuilder(..builder, subscriptions:) -} - -pub fn with_retry_attempts( - builder: DispatchBuilder( - command, - state, - event, - encoded_event, - recorded_event, - domain_error, - subscription_error, - connection, - decode_error, - ), - attempts attempts: Int, -) -> DispatchBuilder( - command, - state, - event, - encoded_event, - recorded_event, - domain_error, - subscription_error, - connection, - decode_error, -) { - DispatchBuilder(..builder, retry_attempts: int.max(attempts, 1)) +/// Build a reusable model from a command-selected decider and JSON codec. +/// +/// ```gleam +/// let model = +/// factos.model( +/// decider: user.decider, +/// encode: user.encode, +/// decode: user.decode, +/// ) +/// ``` +pub fn model( + decider decider: fn(command) -> Decider(command, state, event, domain_error), + encode encode: fn(event) -> Event(json.Json), + decode decode: fn(EventType, Int) -> Result(decode.Decoder(event), Nil), +) -> Model(command, state, event, domain_error) { + Model(decider:, encode:, decode:) } /// A store-visible event type name. @@ -181,7 +109,7 @@ pub fn with_retry_attempts( /// Event types are part of the decision-context selection contract. A backend /// may use them for efficient context reads, and applications should keep names /// stable enough for stored history to remain decodable. -pub opaque type EventType { +pub type EventType { EventType(String) } @@ -191,7 +119,7 @@ pub opaque type EventType { /// select the facts relevant to a decision. For example, an event payload may /// contain `username: "renata"`, while the stored event also carries the tag /// `username:renata`. -pub opaque type Tag { +pub type Tag { Tag(String) } @@ -204,10 +132,7 @@ pub opaque type Metadata { Metadata(List(#(String, String))) } -/// Store-visible details derived from a domain event. -/// -/// Applications can use the same event description when adapting domain events -/// to backend-specific proposals and deterministic simulations. +@internal pub type EventDescriptor { EventDescriptor( type_: EventType, @@ -265,7 +190,7 @@ pub type AppendCondition { FailIfEventsMatch(decision_context: DecisionContext, after: SequencePosition) } -pub type Decider(command, state, event, domain_error) { +pub opaque type Decider(command, state, event, domain_error) { /// A pure command-side domain component. /// /// `initial` is the empty decision state. `evolve` folds accepted events into @@ -312,6 +237,7 @@ pub type Dispatch(event) { Dispatch(position: SequencePosition, events: List(Recorded(event))) } +@internal pub type Context(event, state) { /// A command context read from history. /// @@ -327,32 +253,6 @@ pub type Context(event, state) { ) } -/// Wrap an event type name. -pub fn event_type(name: String) -> EventType { - EventType(name) -} - -/// Unwrap an event type name. -pub fn event_type_to_string(type_: EventType) -> String { - let EventType(name) = type_ - name -} - -pub fn event_version(recorded_event: Recorded(event)) -> Int { - recorded_event.event.descriptor.version -} - -/// Wrap a tag value. -pub fn tag(value: String) -> Tag { - Tag(value) -} - -/// Unwrap a tag value. -pub fn tag_value(tag: Tag) -> String { - let Tag(value) = tag - value -} - /// No event metadata. pub fn empty_metadata() -> Metadata { Metadata([]) @@ -363,41 +263,8 @@ pub fn metadata(entries: List(#(String, String))) -> Metadata { Metadata(entries) } -/// Unwrap event metadata entries. -pub fn metadata_entries(metadata: Metadata) -> List(#(String, String)) { - let Metadata(entries) = metadata - entries -} - -/// Return the first metadata value stored for a key. -pub fn metadata_get(metadata: Metadata, key: String) -> Result(String, Nil) { - let Metadata(entries) = metadata - - entries - |> list.find(fn(entry) { entry.0 == key }) - |> result.map(fn(entry) { entry.1 }) -} - -/// Store a metadata value, replacing any existing values for the same key. -pub fn metadata_put( - metadata: Metadata, - key: String, - value: String, -) -> Metadata { - let Metadata(entries) = metadata - - Metadata([#(key, value), ..list.filter(entries, fn(entry) { entry.0 != key })]) -} - -/// Remove any metadata values stored for a key. -pub fn metadata_remove(metadata: Metadata, key: String) -> Metadata { - let Metadata(entries) = metadata - - Metadata(list.filter(entries, fn(entry) { entry.0 != key })) -} - /// Prepare a domain event with empty tags and metadata for persistence. -pub fn new_event( +pub fn event( type_ type_: EventType, version version: Int, data payload: payload, @@ -432,38 +299,6 @@ pub fn with_metadata( ) } -/// Metadata key used to correlate all facts and effects for one operation. -pub const correlation_id: String = "correlation_id" - -/// Metadata key used to identify the fact or effect that caused this one. -pub const causation_id: String = "causation_id" - -/// Metadata key used to identify the bounded context that produced a fact. -pub const source_context: String = "source_context" - -/// Metadata key used to identify the source event id for an integration effect. -pub const source_event_id: String = "source_event_id" - -/// Metadata key used to identify the source event global position. -pub const source_position: String = "source_position" - -/// Metadata key used to carry the actor responsible for a fact or effect. -pub const actor: String = "actor" - -/// Metadata key used to carry the idempotency key for command deduplication. -pub const idempotency_key: String = "idempotency_key" - -/// Metadata key used to carry the public operation id. -pub const operation_id: String = "operation_id" - -/// Build one selective command-context branch. -/// -/// Event types are OR-combined. Tags are AND-combined. Empty lists act as wildcards -/// for that part of the item. -pub fn item(types types: List(EventType), tags tags: List(Tag)) -> Item { - Item(types:, tags:) -} - /// Build a pure command-side decider. /// /// The supplied functions remain owned by the application domain. Factos only @@ -487,33 +322,28 @@ pub fn decider( /// Backends use this after decoding stored events. Only the domain event payload is /// passed to `evolve`; storage metadata is ignored for state computation. pub fn evolve_recorded( - initial initial: state, + decider: Decider(command, state, event, domain_error), events events: List(Recorded(event)), - evolve evolve: fn(state, event) -> state, ) -> state { - use state, recorded <- list.fold(events, initial) - evolve(state, recorded.event.payload) + use state, recorded <- list.fold(events, decider.initial) + decider.evolve(state, recorded.event.payload) } -/// Run a command against a previously read context. -/// -/// The returned tuple preserves the original context alongside the newly produced -/// events so a backend can append them with `context.append_condition`. +@internal pub fn decide_context( context: Context(event, state), command: command, decider: Decider(command, state, event, domain_error), -) -> Result(#(Context(event, state), List(event)), domain_error) { - use events <- result.try(decider.decide(context.state, command)) - Ok(#(context, events)) +) -> Result(List(event), domain_error) { + decider.decide(context.state, command) } /// Test whether a recorded event belongs to a decision context. pub fn matches_decision_context( - recorded: Recorded(event), + event: Event(event), decision_context: DecisionContext, ) -> Bool { - matches_descriptor(recorded.event.descriptor, decision_context) + matches_descriptor(event.descriptor, decision_context) } /// Test whether an event descriptor belongs to a decision context. @@ -586,3 +416,10 @@ fn matches_tags(event_tags: List(Tag), required_tags: List(Tag)) -> Bool { } } } + +pub fn metadata_to_json(metadata: Metadata) -> json.Json { + let Metadata(entries) = metadata + entries + |> list.map(fn(entry) { #(entry.0, json.string(entry.1)) }) + |> json.object +} diff --git a/src/factos/simulate.gleam b/src/factos/simulate.gleam index a8aaee9..50db45c 100644 --- a/src/factos/simulate.gleam +++ b/src/factos/simulate.gleam @@ -1,72 +1,202 @@ //// Deterministic in-memory execution of Factos domain scenarios. //// -//// The simulator composes the same decision-context, append-condition, and -//// decider semantics used by storage backends without modelling backend IO. +//// The simulator runs the same model, decision-context, decider, and codec +//// semantics used by storage backends without modelling backend IO. +//// +//// Every function returns the immutable simulation so a complete scenario can +//// be written as one pipeline: +//// +//// ```gleam +//// simulate.new(model) +//// |> simulate.given([UsernameReserved(username: "renata")]) +//// |> simulate.dispatch( +//// decision_context: username_context("renata"), +//// command: RegisterUser(username: "renata"), +//// ) +//// |> simulate.assert_events([ +//// UsernameReserved(username: "renata"), +//// UserRegistered(username: "renata"), +//// ]) +//// |> simulate.assert_errors([]) +//// ``` +//// +//// Domain and codec failures are accumulated for `assert_errors`. A rejected +//// command does not append events, so later stages can continue the scenario. import factos import gleam/int +import gleam/json import gleam/list +import gleam/result -/// An immutable in-memory log of recorded domain events. -pub opaque type Store(event) { - Store( - records_reversed: List(factos.Recorded(event)), - describe_event: fn(event) -> factos.EventDescriptor, +/// An immutable event history and accumulated errors for one domain model. +pub opaque type Simulation(event, command, state, domain_error) { + Simulation( + model: factos.Model(command, state, event, domain_error), + reversed_events: List(factos.Recorded(json.Json)), next_position: Int, + reversed_errors: List( + factos.Error(domain_error, Nil, Nil, json.DecodeError), + ), ) } -/// The result of recording one accepted batch. -pub type Commit(event) { - Commit(store: Store(event), events: List(factos.Recorded(event))) +/// Create an empty simulation from a store-independent Factos model. +/// +/// The model's decider factory and codec are reused for every `dispatch`. +/// +/// ```gleam +/// let simulation = simulate.new(user_model) +/// ``` +pub fn new( + model: factos.Model(command, state, event, domain_error), +) -> Simulation(event, command, state, domain_error) { + Simulation(model:, reversed_events: [], next_position: 1, reversed_errors: []) } -/// A rejected simulator append condition. -pub type AppendError { - AppendConditionFailed(condition: factos.AppendCondition) +/// Seed facts that were accepted before the simulated scenario. +/// +/// Seeded events receive deterministic ids and positions. `given` does not run a +/// command or model backend concurrency. +/// +/// ```gleam +/// let simulation = +/// simulate.new(user_model) +/// |> simulate.given([UserRegistered(username: "renata")]) +/// ``` +pub fn given( + simulation: Simulation(event, command, state, domain_error), + events: List(event), +) -> Simulation(event, command, state, domain_error) { + let #(stored_events, next_position) = + prepare_events( + events, + simulation.model.encode, + simulation.next_position, + [], + ) + commit_events(simulation, stored_events, next_position) } -/// Create an empty simulator store. -pub fn new( - describe_event describe_event: fn(event) -> factos.EventDescriptor, -) -> Store(event) { - Store(records_reversed: [], describe_event:, next_position: 1) +/// Read the decision context, decide the command, and append accepted events. +/// +/// The model selects its decider from the command. Domain, schema, and payload +/// errors are accumulated without stopping the pipeline. +/// +/// ```gleam +/// let simulation = +/// simulation +/// |> simulate.dispatch( +/// decision_context: username_context("renata"), +/// command: RegisterUser(username: "renata"), +/// ) +/// ``` +pub fn dispatch( + simulation: Simulation(event, command, state, domain_error), + decision_context decision_context: factos.DecisionContext, + command command: command, +) -> Simulation(event, command, state, domain_error) { + let decider = simulation.model.decider(command) + case read_context(simulation, decision_context, decider) { + Error(error) -> add_error(simulation, error) + Ok(context) -> + case factos.decide_context(context, command, decider) { + Error(error) -> add_error(simulation, factos.DomainError(error)) + Ok(events) -> given(simulation, events) + } + } } -/// Seed events that the domain has already accepted. -pub fn given(store: Store(event), events events: List(event)) -> Store(event) { - let #(store, _) = record_batch(store, events) - store +/// Assert the complete decoded event history in append order. +/// +/// A mismatch fails immediately with Gleam's structural assertion diff. Codec +/// failures are accumulated for `assert_errors`. +/// +/// ```gleam +/// simulation +/// |> simulate.assert_events([ +/// UserRegistered(username: "renata"), +/// ]) +/// ``` +pub fn assert_events( + simulation: Simulation(event, command, state, domain_error), + events: List(event), +) -> Simulation(event, command, state, domain_error) { + case decode_all_events(simulation) { + Ok(simulation_events) -> { + assert simulation_events == events + simulation + } + Error(error) -> add_error(simulation, error) + } } -/// Read recorded events selected by a decision context. -pub fn read( - store: Store(event), - decision_context decision_context: factos.DecisionContext, -) -> List(factos.Recorded(event)) { - let Store(records_reversed:, ..) = store - - records_reversed - |> list.filter(factos.matches_decision_context(_, decision_context)) - |> list.reverse +/// Assert collected Factos errors in scenario order. +/// +/// ```gleam +/// simulation +/// |> simulate.assert_errors([ +/// factos.DomainError(UsernameAlreadyTaken), +/// ]) +/// ``` +pub fn assert_errors( + simulation: Simulation(event, command, state, domain_error), + errors: List(factos.Error(domain_error, Nil, Nil, json.DecodeError)), +) -> Simulation(event, command, state, domain_error) { + assert list.reverse(simulation.reversed_errors) == errors + simulation } -/// Read and fold the state selected by a decision context. -pub fn read_context( - store: Store(event), - decision_context decision_context: factos.DecisionContext, - decider decider: factos.Decider(command, state, event, domain_error), -) -> factos.Context(event, state) { - let factos.Decider(initial:, evolve:, ..) = decider - let events = read(store, decision_context) - let position = - list.fold(events, factos.NoPosition, fn(position, recorded) { - factos.highest_position(position, recorded.position) - }) +fn read( + simulation: Simulation(event, command, state, domain_error), + decision_context: factos.DecisionContext, +) -> Result( + List(factos.Recorded(event)), + factos.Error(domain_error, Nil, Nil, json.DecodeError), +) { + simulation.reversed_events + |> list.filter(fn(recorded) { + factos.matches_decision_context(recorded.event, decision_context) + }) + |> list.try_map(fn(recorded: factos.Recorded(json.Json)) { + let type_ = recorded.event.descriptor.type_ + let version = recorded.event.descriptor.version + use decoder <- result.try( + simulation.model.decode(type_, version) + |> result.replace_error(factos.InvalidSchema(type_, version)), + ) + use event <- result.try( + recorded.event.payload + |> json.to_string + |> json.parse(decoder) + |> result.map_error(factos.DecodeError), + ) + Ok( + factos.Recorded( + ..recorded, + event: factos.Event( + payload: event, + descriptor: recorded.event.descriptor, + ), + ), + ) + }) + |> result.map(list.reverse) +} +fn read_context( + simulation, + decision_context: factos.DecisionContext, + decider: factos.Decider(command, state, event, domain_error), +) -> Result( + factos.Context(event, state), + factos.Error(domain_error, Nil, Nil, json.DecodeError), +) { + use events <- result.map(read(simulation, decision_context)) + let position = factos.highest_recorded_position(events) factos.Context( decision_context:, - state: factos.evolve_recorded(initial:, events:, evolve:), + state: factos.evolve_recorded(decider, events:), events:, position:, append_condition: factos.FailIfEventsMatch( @@ -76,104 +206,58 @@ pub fn read_context( ) } -/// Record events when the supplied command context is still current. -pub fn append( - store: Store(event), - events events: List(event), - condition condition: factos.AppendCondition, -) -> Result(Commit(event), AppendError) { +fn prepare_events( + events: List(event), + encode: fn(event) -> factos.Event(json.Json), + next_position: Int, + stored_reversed: List(factos.Recorded(json.Json)), +) -> #(List(factos.Recorded(json.Json)), Int) { case events { - [] -> Ok(Commit(store:, events: [])) - [_, ..] -> - case append_condition_failed(store, condition) { - True -> Error(AppendConditionFailed(condition:)) - False -> { - let #(store, events) = record_batch(store, events) - Ok(Commit(store:, events:)) - } - } - } -} - -/// Read, decide, and record one command as an atomic immutable transition. -pub fn dispatch( - store: Store(event), - decision_context context: factos.DecisionContext, - decider decider: factos.Decider(command, state, event, domain_error), - command command: command, -) -> Result(Commit(event), domain_error) { - let context = read_context(store, context, decider) - - case factos.decide_context(context, command, decider) { - Error(error) -> Error(error) - Ok(#(_, events)) -> { - let #(store, events) = record_batch(store, events) - Ok(Commit(store:, events:)) + [] -> #(list.reverse(stored_reversed), next_position) + [event, ..rest] -> { + let encoded = encode(event) + let stored = + factos.Recorded( + id: int.to_string(next_position), + position: factos.SequencePosition(next_position), + event: encoded, + ) + prepare_events(rest, encode, next_position + 1, [ + stored, + ..stored_reversed + ]) } } } -fn append_condition_failed( - store: Store(event), - condition: factos.AppendCondition, -) -> Bool { - let factos.FailIfEventsMatch(decision_context:, after:) = condition - - let Store(records_reversed:, ..) = store - - list.any(records_reversed, fn(recorded) { - factos.matches_decision_context(recorded, decision_context) - && case after, recorded.position { - factos.NoPosition, factos.NoPosition -> True - factos.NoPosition, factos.SequencePosition(_) -> True - factos.SequencePosition(_), factos.NoPosition -> False - factos.SequencePosition(after), factos.SequencePosition(position) -> - position > after - } - }) +fn commit_events( + simulation: Simulation(event, command, state, domain_error), + stored_events: List(factos.Recorded(json.Json)), + next_position: Int, +) -> Simulation(event, command, state, domain_error) { + Simulation( + ..simulation, + reversed_events: list.append( + list.reverse(stored_events), + simulation.reversed_events, + ), + next_position:, + ) } -fn record_batch( - store: Store(event), - events: List(event), -) -> #(Store(event), List(factos.Recorded(event))) { - case events { - [] -> #(store, []) - [_, ..] -> { - let Store(records_reversed:, describe_event:, next_position:) = store - let #(records_reversed, batch_reversed, next_position) = - list.fold( - events, - #(records_reversed, [], next_position), - fn(accumulator, payload) { - let #(records_reversed, batch_reversed, next_position) = accumulator - let factos.EventDescriptor(type_:, version:, tags:, metadata:) = - describe_event(payload) - let recorded = - factos.Recorded( - id: "factos-simulate-" <> int.to_string(next_position), - position: factos.SequencePosition(next_position), - event: factos.Event( - payload:, - descriptor: factos.EventDescriptor( - type_:, - version:, - tags:, - metadata:, - ), - ), - ) - - #( - [recorded, ..records_reversed], - [recorded, ..batch_reversed], - next_position + 1, - ) - }, - ) - let store = Store(records_reversed:, describe_event:, next_position:) +fn decode_all_events( + simulation: Simulation(event, command, state, domain_error), +) -> Result(List(event), factos.Error(domain_error, Nil, Nil, json.DecodeError)) { + use events <- result.map(read(simulation, factos.AllEvents)) + list.map(events, fn(recorded) { recorded.event.payload }) +} - #(store, list.reverse(batch_reversed)) - } - } +fn add_error( + simulation: Simulation(event, command, state, domain_error), + error: factos.Error(domain_error, Nil, Nil, json.DecodeError), +) -> Simulation(event, command, state, domain_error) { + Simulation(..simulation, reversed_errors: [ + error, + ..simulation.reversed_errors + ]) } diff --git a/test/factos_test.gleam b/test/factos_test.gleam index 518cace..e6f4eb3 100644 --- a/test/factos_test.gleam +++ b/test/factos_test.gleam @@ -1,38 +1,21 @@ import factos import factos/simulate +import username +import gleam/dynamic/decode import gleam/int -import gleam/list +import gleam/json import gleeunit pub fn main() -> Nil { gleeunit.main() } -type Event { - UsernameReserved(username: String) - UserRegistered(username: String) - DisplayNameChanged(name: String) -} - -type State { - UsernameAvailable - UsernameTaken -} - -type Command { - RegisterUser(username: String) -} - -type DomainError { - UsernameAlreadyTaken -} - pub fn decide_context_uses_decider_test() { let context = factos.Context( decision_context: factos.AllEvents, - state: UsernameAvailable, + state: username.UsernameAvailable, events: [], position: factos.NoPosition, append_condition: factos.FailIfEventsMatch( @@ -43,108 +26,94 @@ pub fn decide_context_uses_decider_test() { assert factos.decide_context( context, - RegisterUser("renata"), - username_decider(), + username.RegisterUser("renata"), + username.decider(), ) - == Ok(#(context, [UserRegistered("renata")])) + == Ok([username.UserRegistered("renata")]) } pub fn query_matches_by_type_and_tags_test() { let username_query = factos.Matching([ - factos.item( + factos.Item( types: [ - factos.event_type("UsernameReserved"), - factos.event_type("UserRegistered"), + factos.EventType("UsernameReserved"), + factos.EventType("UserRegistered"), ], - tags: [factos.tag("username:renata")], + tags: [factos.Tag("username:renata")], ), ]) let matching = recorded( - UsernameReserved("renata"), - [factos.tag("username:renata")], + username.UsernameReserved("renata"), + [factos.Tag("username:renata")], position: 0, ) let wrong_tag = recorded( - UsernameReserved("lucy"), - [factos.tag("username:lucy")], + username.UsernameReserved("lucy"), + [factos.Tag("username:lucy")], position: 1, ) let wrong_type = recorded( - DisplayNameChanged("Renata"), - [factos.tag("username:renata")], + username.DisplayNameChanged("Renata"), + [factos.Tag("username:renata")], position: 2, ) - assert factos.matches_decision_context(matching, username_query) - assert !factos.matches_decision_context(wrong_tag, username_query) - assert !factos.matches_decision_context(wrong_type, username_query) + assert factos.matches_decision_context(matching.event, username_query) + assert !factos.matches_decision_context(wrong_tag.event, username_query) + assert !factos.matches_decision_context(wrong_type.event, username_query) } pub fn query_items_are_or_combined_test() { let query = factos.Matching([ - factos.item(types: [factos.event_type("UserRegistered")], tags: [ - factos.tag("user:1"), + factos.Item(types: [factos.EventType("UserRegistered")], tags: [ + factos.Tag("user:1"), ]), - factos.item(types: [factos.event_type("DisplayNameChanged")], tags: [ - factos.tag("user:2"), + factos.Item(types: [factos.EventType("DisplayNameChanged")], tags: [ + factos.Tag("user:2"), ]), ]) let event = - recorded(DisplayNameChanged("R"), [factos.tag("user:2")], position: 0) + recorded( + username.DisplayNameChanged("R"), + [factos.Tag("user:2")], + position: 0, + ) - assert factos.matches_decision_context(event, query) + assert factos.matches_decision_context(event.event, query) } pub fn empty_matching_context_matches_no_events_test() { let decision_context = factos.Matching(items: []) - let event = recorded(DisplayNameChanged("R"), [], position: 0) - - assert !factos.matches_decision_context(event, decision_context) -} - -pub fn metadata_helpers_get_put_and_remove_values_test() { - let metadata = - factos.metadata([ - #("correlation_id", "old"), - #("actor", "user_123"), - ]) - |> factos.metadata_put(factos.correlation_id, "new") + let event = recorded(username.DisplayNameChanged("R"), [], position: 0) - assert factos.metadata_get(metadata, factos.correlation_id) == Ok("new") - assert factos.metadata_get(metadata, factos.actor) == Ok("user_123") - assert factos.metadata_get(metadata, factos.operation_id) == Error(Nil) - - let metadata = factos.metadata_remove(metadata, factos.correlation_id) - - assert factos.metadata_get(metadata, factos.correlation_id) == Error(Nil) - assert factos.metadata_get(metadata, factos.actor) == Ok("user_123") + assert !factos.matches_decision_context(event.event, decision_context) } pub fn proposed_event_builders_replace_tags_and_metadata_test() -> Nil { let metadata = factos.metadata([#("source", "registration")]) let proposed = - factos.new_event( - type_: factos.event_type("UserRegistered"), + factos.event( + type_: factos.EventType("UserRegistered"), version: 1, data: "renata", ) - |> factos.with_tags(tags: [factos.tag("username:renata")]) + |> factos.with_tags(tags: [factos.Tag("username:renata")]) |> factos.with_metadata(metadata:) assert proposed == factos.Event( payload: "renata", descriptor: factos.EventDescriptor( - type_: factos.event_type("UserRegistered"), + type_: factos.EventType("UserRegistered"), version: 1, - tags: [factos.tag("username:renata")], + tags: [factos.Tag("username:renata")], metadata:, ), ) @@ -160,9 +129,9 @@ pub fn highest_position_keeps_later_position_test() { pub fn dispatch_records_final_position_without_an_append_wrapper_test() -> Nil { let events = [ - recorded(UserRegistered("renata"), [], position: 10), - recorded(UserRegistered("lucy"), [], position: 12), - recorded(UserRegistered("marc"), [], position: 11), + recorded(username.UserRegistered("renata"), [], position: 10), + recorded(username.UserRegistered("lucy"), [], position: 12), + recorded(username.UserRegistered("marc"), [], position: 11), ] let position = factos.highest_recorded_position(events) let dispatch = factos.Dispatch(position:, events:) @@ -172,449 +141,107 @@ pub fn dispatch_records_final_position_without_an_append_wrapper_test() -> Nil { assert factos.highest_recorded_position([]) == factos.NoPosition } -pub fn simulate_given_assigns_deterministic_record_envelopes_test() -> Nil { - let store = simulate.new(describe_event) - let store = - simulate.given(store, events: [ - UsernameReserved(username: "renata"), - UserRegistered(username: "renata"), - ]) - let store = simulate.given(store, events: []) - let store = - simulate.given(store, events: [DisplayNameChanged(name: "Renata")]) - - assert simulate.read(store, factos.AllEvents) - == [ - simulated_record(UsernameReserved(username: "renata"), position: 1), - simulated_record(UserRegistered(username: "renata"), position: 2), - simulated_record(DisplayNameChanged(name: "Renata"), position: 3), - ] -} - -pub fn simulate_read_and_context_use_query_order_and_highest_match_test() -> Nil { - let store = simulate.new(describe_event) - let store = - simulate.given(store, events: [ - UserRegistered(username: "renata"), - UserRegistered(username: "lucy"), - UserRegistered(username: "marc"), - ]) - let renata = simulated_record(UserRegistered(username: "renata"), position: 1) - let lucy = simulated_record(UserRegistered(username: "lucy"), position: 2) - let marc = simulated_record(UserRegistered(username: "marc"), position: 3) - - let decision_context = empty_query() - assert simulate.read(store, decision_context) == [] - assert simulate.read_context(store, decision_context, username_decider()) - == factos.Context( - decision_context:, - state: UsernameAvailable, - events: [], - position: factos.NoPosition, - append_condition: factos.FailIfEventsMatch( - decision_context:, - after: factos.NoPosition, - ), - ) - - let decision_context = username_conformance_context() - assert simulate.read(store, decision_context) == [lucy] - assert simulate.read_context(store, decision_context, username_decider()) - == factos.Context( - decision_context:, - state: UsernameTaken, - events: [lucy], - position: factos.SequencePosition(2), - append_condition: factos.FailIfEventsMatch( - decision_context:, - after: factos.SequencePosition(2), - ), - ) - - let decision_context = factos.AllEvents - assert simulate.read(store, factos.AllEvents) == [renata, lucy, marc] - assert simulate.read_context(store, decision_context, username_decider()) - == factos.Context( - decision_context:, - state: UsernameTaken, - events: [renata, lucy, marc], - position: factos.SequencePosition(3), - append_condition: factos.FailIfEventsMatch( - decision_context:, - after: factos.SequencePosition(3), - ), - ) -} - -pub fn simulate_no_context_ignores_recorded_history_test() -> Nil { - let store = - simulate.given(simulate.new(describe_event), events: [ - UsernameReserved(username: "renata"), - ]) - - assert simulate.read_context(store, factos.NoContext, username_decider()) - == factos.Context( - decision_context: factos.NoContext, - state: UsernameAvailable, - events: [], - position: factos.NoPosition, - append_condition: factos.FailIfEventsMatch( - factos.NoContext, - factos.NoPosition, - ), - ) - - let assert Ok(simulate.Commit(store:, events: committed)) = - simulate.dispatch( - store, - decision_context: factos.NoContext, - decider: username_decider(), - command: RegisterUser(username: "renata"), - ) - let registered = - simulated_record(UserRegistered(username: "renata"), position: 2) - - assert committed == [registered] - assert simulate.read(store, factos.AllEvents) - == [ - simulated_record(UsernameReserved(username: "renata"), position: 1), - registered, - ] -} - -pub fn simulate_append_records_batch_in_order_without_condition_test() -> Nil { - let events = [ - UsernameReserved(username: "renata"), - UserRegistered(username: "renata"), - ] - let assert Ok(simulate.Commit(store:, events: committed)) = - simulate.append( - simulate.new(describe_event), - events:, - condition: factos.FailIfEventsMatch( - decision_context: factos.NoContext, - after: factos.NoPosition, - ), - ) - let expected = [ - simulated_record(UsernameReserved(username: "renata"), position: 1), - simulated_record(UserRegistered(username: "renata"), position: 2), - ] - - assert committed == expected - assert simulate.read(store, factos.AllEvents) == expected -} - -pub fn simulate_dispatch_reuses_matching_recorded_facts_test() -> Nil { - let decision_context = username_context("renata") - let assert Ok(simulate.Commit(store:, events: committed)) = - simulate.dispatch( - simulate.new(describe_event), - decision_context:, - decider: username_decider(), - command: RegisterUser(username: "renata"), - ) - let expected = [ - simulated_record(UserRegistered(username: "renata"), position: 1), - ] - - assert committed == expected - let assert Error(error) = - simulate.dispatch( - store, - decision_context:, - decider: username_decider(), - command: RegisterUser(username: "renata"), - ) - assert error == UsernameAlreadyTaken - assert simulate.read(store, factos.AllEvents) == expected -} - -pub fn simulate_append_without_position_requires_empty_match_set_test() -> Nil { - let decision_context = username_context("renata") - let context = - simulate.read_context( - simulate.new(describe_event), - decision_context, - username_decider(), - ) - let condition = context.append_condition - - assert context.position == factos.NoPosition - assert condition - == factos.FailIfEventsMatch(decision_context:, after: factos.NoPosition) - - let assert Ok(simulate.Commit(store:, events: first_events)) = - simulate.append( - simulate.new(describe_event), - events: [UserRegistered(username: "renata")], - condition:, - ) - assert first_events - == [simulated_record(UserRegistered(username: "renata"), position: 1)] - - let assert Error(simulate.AppendConditionFailed(condition: rejected)) = - simulate.append( - store, - events: [UserRegistered(username: "renata")], - condition:, - ) - assert rejected == condition -} - -pub fn simulate_stale_context_append_rejects_later_match_test() -> Nil { - let decision_context = username_context("renata") - let initial_store = - simulate.given(simulate.new(describe_event), events: [ - UserRegistered(username: "renata"), - ]) - let context = - simulate.read_context(initial_store, decision_context, username_decider()) - let condition = context.append_condition - let interleaved_store = - simulate.given(initial_store, events: [ - UsernameReserved(username: "renata"), - ]) - let accepted_before_rejection = [ - simulated_record(UserRegistered(username: "renata"), position: 1), - simulated_record(UsernameReserved(username: "renata"), position: 2), - ] - - assert context.position == factos.SequencePosition(1) - let assert Error(simulate.AppendConditionFailed(condition: rejected)) = - simulate.append( - interleaved_store, - events: [ - UsernameReserved(username: "renata"), - UserRegistered(username: "renata"), - ], - condition:, - ) - assert rejected == condition - assert simulate.read(interleaved_store, factos.AllEvents) - == accepted_before_rejection - - let assert Ok(simulate.Commit(store:, events: committed)) = - simulate.append( - interleaved_store, - events: [DisplayNameChanged(name: "Renata")], - condition: factos.FailIfEventsMatch( - decision_context: factos.NoContext, - after: factos.NoPosition, - ), - ) - let next = simulated_record(DisplayNameChanged(name: "Renata"), position: 3) - - assert committed == [next] - assert simulate.read(store, factos.AllEvents) - == list.append(accepted_before_rejection, [next]) +pub fn simulate_then_returns_all_events_in_append_order_test() { + simulate.new(username.model()) + |> simulate.given([ + username.UserRegistered(username: "renata"), + username.UserRegistered(username: "lucy"), + username.UserRegistered(username: "marc"), + ]) + |> simulate.assert_events([ + username.UserRegistered(username: "renata"), + username.UserRegistered(username: "lucy"), + username.UserRegistered(username: "marc"), + ]) } -pub fn simulate_stale_context_append_allows_unrelated_later_event_test() -> Nil { - let decision_context = username_context("renata") - let initial_store = - simulate.given(simulate.new(describe_event), events: [ - UserRegistered(username: "renata"), - ]) - let condition = - simulate.read_context(initial_store, decision_context, username_decider()).append_condition - let interleaved_store = - simulate.given(initial_store, events: [ - DisplayNameChanged(name: "Renata"), - ]) - let assert Ok(simulate.Commit(store:, events: committed)) = - simulate.append( - interleaved_store, - events: [UsernameReserved(username: "renata")], - condition:, - ) - let appended = - simulated_record(UsernameReserved(username: "renata"), position: 3) - let expected = [ - simulated_record(UserRegistered(username: "renata"), position: 1), - simulated_record(DisplayNameChanged(name: "Renata"), position: 2), - appended, - ] - - assert committed == [appended] - assert simulate.read(store, factos.AllEvents) == expected +pub fn simulate_fluent_given_when_then_test() { + simulate.new(username.model()) + |> simulate.given([username.UsernameReserved(username: "renata")]) + |> simulate.assert_events([ + username.UsernameReserved(username: "renata"), + ]) + |> simulate.dispatch( + decision_context: factos.NoContext, + command: username.RegisterUser(username: "renata"), + ) + |> simulate.assert_events([ + username.UsernameReserved(username: "renata"), + username.UserRegistered(username: "renata"), + ]) + |> simulate.assert_errors([]) } -pub fn simulate_empty_append_ignores_stale_condition_test() -> Nil { - let decision_context = username_context("renata") - let initial_store = - simulate.given(simulate.new(describe_event), events: [ - UserRegistered(username: "renata"), - ]) - let condition = - simulate.read_context(initial_store, decision_context, username_decider()).append_condition - let stale_store = - simulate.given(initial_store, events: [ - UsernameReserved(username: "renata"), - ]) - - let assert Ok(simulate.Commit(store:, events: [])) = - simulate.append(stale_store, events: [], condition:) - let assert Ok(simulate.Commit(store:, events: committed)) = - simulate.append( - store, - events: [DisplayNameChanged(name: "Renata")], - condition: factos.FailIfEventsMatch( - decision_context: factos.NoContext, - after: factos.NoPosition, +pub fn simulate_when_reuses_matching_given_facts_test() { + let decision_context = + factos.Matching([ + factos.Item( + types: [ + factos.EventType("UsernameReserved"), + factos.EventType("UserRegistered"), + ], + tags: [factos.Tag("username:renata")], ), - ) - let next = simulated_record(DisplayNameChanged(name: "Renata"), position: 3) - - assert committed == [next] - assert simulate.read(store, factos.AllEvents) - == [ - simulated_record(UserRegistered(username: "renata"), position: 1), - simulated_record(UsernameReserved(username: "renata"), position: 2), - next, - ] -} - -pub fn simulate_empty_dispatch_is_a_no_op_test() -> Nil { - let seeded_store = - simulate.given(simulate.new(describe_event), events: [ - UsernameReserved(username: "renata"), ]) - let seeded = [ - simulated_record(UsernameReserved(username: "renata"), position: 1), - ] - let assert Ok(simulate.Commit(store:, events: [])) = - simulate.dispatch( - seeded_store, - decision_context: factos.AllEvents, - decider: empty_decider(), - command: RegisterUser(username: "ignored"), - ) - - assert simulate.read(store, factos.AllEvents) == seeded - let assert Ok(simulate.Commit(store:, events: committed)) = - simulate.append( - store, - events: [UserRegistered(username: "renata")], - condition: factos.FailIfEventsMatch( - decision_context: factos.NoContext, - after: factos.NoPosition, - ), - ) - let next = simulated_record(UserRegistered(username: "renata"), position: 2) - - assert committed == [next] - assert simulate.read(store, factos.AllEvents) == list.append(seeded, [next]) -} - -fn evolve(state: State, event: Event) -> State { - case state, event { - UsernameAvailable, UsernameReserved(_) -> UsernameTaken - UsernameAvailable, UserRegistered(_) -> UsernameTaken - UsernameAvailable, DisplayNameChanged(_) -> state - UsernameTaken, UsernameReserved(_) -> state - UsernameTaken, UserRegistered(_) -> state - UsernameTaken, DisplayNameChanged(_) -> state - } -} -fn decide(state: State, command: Command) -> Result(List(Event), DomainError) { - case state, command { - UsernameAvailable, RegisterUser(username) -> Ok([UserRegistered(username)]) - UsernameTaken, RegisterUser(_) -> Error(UsernameAlreadyTaken) - } -} - -fn username_decider() -> factos.Decider(Command, State, Event, DomainError) { - factos.decider(initial: UsernameAvailable, decide: decide, evolve: evolve) + simulate.new(username.model()) + |> simulate.dispatch( + decision_context:, + command: username.RegisterUser(username: "renata"), + ) + |> simulate.assert_errors([]) + |> simulate.assert_events([username.UserRegistered("renata")]) + |> simulate.dispatch( + decision_context:, + command: username.RegisterUser(username: "renata"), + ) + |> simulate.assert_errors([factos.DomainError(username.UsernameAlreadyTaken)]) } -fn describe_event(event: Event) -> factos.EventDescriptor { - case event { - UsernameReserved(username:) -> - factos.EventDescriptor( - type_: factos.event_type("UsernameReserved"), - version: 1, - tags: [factos.tag("username:" <> username)], - metadata: factos.empty_metadata(), - ) - UserRegistered(username:) -> - factos.EventDescriptor( - type_: factos.event_type("UserRegistered"), - version: 1, - tags: [factos.tag("username:" <> username)], - metadata: factos.empty_metadata(), - ) - DisplayNameChanged(name: _) -> - factos.EventDescriptor( - type_: factos.event_type("DisplayNameChanged"), - version: 1, - tags: [], - metadata: factos.empty_metadata(), +pub fn simulate_empty_decision_is_a_no_op_test() { + simulate.new(username.model()) + |> simulate.given([username.UsernameReserved(username: "renata")]) + |> simulate.assert_events([username.UsernameReserved(username: "renata")]) + |> simulate.dispatch( + decision_context: factos.AllEvents, + command: username.RegisterUser(username: "ignored"), + ) + |> simulate.assert_events([username.UsernameReserved(username: "renata")]) +} + +pub fn simulate_read_reports_malformed_payload_test() { + factos.model( + encode: username.encode, + decode: fn(_type, _version) { + Ok( + decode.int + |> decode.map(fn(value) { + username.UsernameReserved(username: int.to_string(value)) + }), ) - } -} - -fn username_context(username: String) -> factos.DecisionContext { - factos.Matching([ - factos.item( - types: [ - factos.event_type("UsernameReserved"), - factos.event_type("UserRegistered"), - ], - tags: [factos.tag("username:" <> username)], - ), - ]) -} - -fn empty_query() -> factos.DecisionContext { - factos.Matching(items: []) -} - -fn username_conformance_context() -> factos.DecisionContext { - factos.Matching([ - factos.item(types: [factos.event_type("UserRegistered")], tags: [ - factos.tag("username:renata"), - factos.tag("username:lucy"), - ]), - factos.item( - types: [ - factos.event_type("UnknownEventType"), - factos.event_type("UserRegistered"), - ], - tags: [factos.tag("username:lucy")], + }, + decider: fn(_) { username.decider() }, + ) + |> simulate.new + |> simulate.given([username.UsernameReserved(username: "renata")]) + |> simulate.assert_events([]) + |> simulate.assert_errors([ + factos.DecodeError( + json.UnableToDecode([decode.DecodeError("Int", "String", [])]), ), ]) -} - -fn empty_decider() -> factos.Decider(Command, State, Event, DomainError) { - factos.decider( - initial: UsernameAvailable, - decide: fn(_state, _command) { Ok([]) }, - evolve: evolve, - ) -} - -fn simulated_record( - payload: Event, - position position: Int, -) -> factos.Recorded(Event) { - factos.Recorded( - id: "factos-simulate-" <> int.to_string(position), - position: factos.SequencePosition(position), - event: factos.Event(payload:, descriptor: describe_event(payload)), - ) + |> simulate.assert_events([]) } fn recorded( - payload: Event, + payload: username.Event, tags tags: List(factos.Tag), position position: Int, -) -> factos.Recorded(Event) { +) -> factos.Recorded(username.Event) { let type_ = case payload { - UsernameReserved(_) -> factos.event_type("UsernameReserved") - UserRegistered(_) -> factos.event_type("UserRegistered") - DisplayNameChanged(_) -> factos.event_type("DisplayNameChanged") + username.UsernameReserved(_) -> factos.EventType("UsernameReserved") + username.UserRegistered(_) -> factos.EventType("UserRegistered") + username.DisplayNameChanged(_) -> factos.EventType("DisplayNameChanged") } factos.Recorded( diff --git a/test/username.gleam b/test/username.gleam new file mode 100644 index 0000000..e372184 --- /dev/null +++ b/test/username.gleam @@ -0,0 +1,82 @@ +import factos +import gleam/dynamic/decode +import gleam/json + +pub type Event { + UsernameReserved(username: String) + UserRegistered(username: String) + DisplayNameChanged(name: String) +} + +pub type State { + UsernameAvailable + UsernameTaken +} + +pub type Command { + RegisterUser(username: String) +} + +pub type DomainError { + UsernameAlreadyTaken +} + +pub fn encode(event: Event) -> factos.Event(json.Json) { + let #(type_name, value, tags) = case event { + UsernameReserved(username:) -> #("UsernameReserved", username, [ + factos.Tag("username:" <> username), + ]) + UserRegistered(username:) -> #("UserRegistered", username, [ + factos.Tag("username:" <> username), + ]) + DisplayNameChanged(name:) -> #("DisplayNameChanged", name, []) + } + + factos.event( + type_: factos.EventType(type_name), + version: 1, + data: json.string(value), + ) + |> factos.with_tags(tags:) +} + +fn decode( + type_: factos.EventType, + version: Int, +) -> Result(decode.Decoder(Event), Nil) { + case type_, version { + factos.EventType("UsernameReserved"), 1 -> + Ok(decode.string |> decode.map(UsernameReserved)) + factos.EventType("UserRegistered"), 1 -> + Ok(decode.string |> decode.map(UserRegistered)) + factos.EventType("DisplayNameChanged"), 1 -> + Ok(decode.string |> decode.map(DisplayNameChanged)) + _, _ -> Error(Nil) + } +} + +pub fn evolve(state: State, event: Event) -> State { + case state, event { + UsernameAvailable, UsernameReserved(_) -> UsernameTaken + UsernameAvailable, UserRegistered(_) -> UsernameTaken + UsernameAvailable, DisplayNameChanged(_) -> state + UsernameTaken, UsernameReserved(_) -> state + UsernameTaken, UserRegistered(_) -> state + UsernameTaken, DisplayNameChanged(_) -> state + } +} + +fn decide(state: State, command: Command) -> Result(List(Event), DomainError) { + case state, command { + UsernameAvailable, RegisterUser(username) -> Ok([UserRegistered(username)]) + UsernameTaken, RegisterUser(_) -> Error(UsernameAlreadyTaken) + } +} + +pub fn model() { + factos.model(encode:, decode:, decider: fn(_) { decider() }) +} + +pub fn decider() -> factos.Decider(Command, State, Event, DomainError) { + factos.decider(initial: UsernameAvailable, decide: decide, evolve: evolve) +}