diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..fa735f0 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,89 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +Entries for unreleased work are not written here directly. Each issue drops a +fragment in [`changelog.d/`](changelog.d/README.md); the fragments are assembled +into a version section at release. See that README for the format and for when a +change warrants an entry at all. + +## [0.1.0] 2026-08-22 + +First release: the persistence-first execution loop for the +[statifier](https://hex.pm/packages/statifier) statechart engine - load a +persisted position, step it, execute the effects, persist - packaged as a +storage-adapter behaviour with an identity guard, an in-memory reference +adapter, a run lifecycle, and an Ecto/Postgres layer, all covered by one +conformance suite downstream adapters inherit. + +### Added + +- `StatifierPersistence.Storage.Adapter`, the storage contract, including + run records: `insert_run/2`, `fetch_run/2`, and `update_run/2` callbacks + with `run_record`/`run_status` types and the `:run_exists` / + `:run_not_found` error arms; `StatifierPersistence.Storage.InMemory` is + the reference implementation. +- Guarded run access on the facade: `StatifierPersistence.Storage.insert_run/5`, + `update_run/5`, `fetch_run/2`, and `load_run_position/3` (identity-guarded, + with the `:run_position_missing` arm for a run persisted without a + position). +- Run-record conformance tests in + `StatifierPersistence.Testing.StorageConformance`, so downstream adapters + inherit the same contract checks. +- The run lifecycle as a library: `StatifierPersistence.Runs.create/4` and + `step/5` drive the load -> re-stamp -> step -> execute -> persist loop + over durable run records, handing effects to a host-supplied + `StatifierPersistence.Executor` (behaviour or arity-2 fun) and returning + the host-facing `StatifierPersistence.Run` struct; events to a terminal + run come back as `{:discarded, run}`. +- Failure semantics on the loop: executor failures on actionable effects + re-enter the chart as `error.communication` events (single wave per step, + observational failures discarded); effect execution is at-least-once, with + a failed persist re-driving the same event and re-emitting the same + effects under identical deterministic keys; budget exhaustion persists a + `:failed` run (position untouched) and returns + `{:error, {:budget_exhausted, payload}}`. +- `StatifierPersistence.Runs.fail/4`, the host-driven abandonment: marks an + active run `:failed` with a reason, leaves the stored position untouched, + and discards on a terminal run - backed by the status-only writer + `StatifierPersistence.Storage.update_run_status/4`. +- Pluggable per-run serialization: the `StatifierPersistence.Serialization` + behaviour (`with_run/3`), selected per lifecycle call with + `serialization: {module, config}` on `Runs.create/4`, `step/5`, and + `fail/4`. The default strategy, + `StatifierPersistence.Serialization.AdapterLock`, delegates to the + optional adapter callback + `StatifierPersistence.Storage.Adapter.lock_run/3` (implemented by + `InMemory`, conformance-tested when exported) and refuses with + `{:error, {:serialization, :not_supported}}` when the adapter does not + export it. +- `use StatifierPersistence.Ecto`: compile-time configuration on the host's + module (`repo:`, `key:`, `table_prefix:`, `tables:`, `prefix:`) that + defines `Chart`, `Position`, and `Run` schema modules and exposes the + resolved config via `__statifier_persistence__/1`. Requires the optional + `ecto_sql` dependency. +- `StatifierPersistence.Ecto.KeyGenerator`: the behaviour a surrogate-key + scheme implements, with `:uxid` (default), `:uuid` (UUIDv7), `:bigserial`, + and `{module, opts}` resolved through `resolve/1`. +- `StatifierPersistence.Ecto.Migrations`: the versioned migrations helper + (`up/1`, `down/1`, taking `for: HostModule` or the same literal options + `use` takes) that creates the `charts`/`positions`/`runs` tables from the + same resolved config the schemas use. +- `StatifierPersistence.Storage.Ecto`: the Postgres storage adapter over + the schemas a host generates with `use StatifierPersistence.Ecto` + (`Storage.new(Storage.Ecto, persistence: MyApp.Persistence)`). Passes + the same conformance suite as the in-memory reference adapter; engine + identities stored verbatim; `:run_exists` enforced atomically by the + unique index. +- `Storage.Ecto.isolate/1`: with `sandbox: true`, wraps each test in its + own `Ecto.Adapters.SQL.Sandbox` checkout - the hook host test suites + (and this package's conformance suite) isolate through. +- `Storage.Ecto.lock_run/3`: per-run mutual exclusion as a + transaction-scoped `pg_advisory_xact_lock` plus a `SELECT ... FOR + UPDATE` row lock (ADR-0004 as amended), consumed by + `Serialization.AdapterLock`. +- `uxid` is a required dependency (the default key scheme works out of + the box); `ecto_sql` is optional and the package compiles without it. diff --git a/README.md b/README.md index 5ce4f0b..f8a3996 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,24 @@ span days or survive deploys should not need long-lived Session processes at all - but every host currently hand-rolls the loop, the storage guard, and the crash semantics. This package is that loop, packaged. +## Installation + +```elixir +def deps do + [ + {:statifier_persistence, "~> 0.1"}, + # Optional, for the Postgres adapter: + {:ecto_sql, "~> 3.10"} + ] +end +``` + ## Status -Pre-release, under active development. The storage-adapter behaviour with +Early, under active development. The storage-adapter behaviour with its identity guard, the in-memory reference adapter, the run lifecycle, and the Ecto layer (configurable keys/tables, versioned migrations, and -the Postgres adapter below) exist; nothing is published to Hex yet. +the Postgres adapter below) all exist and are conformance-tested. ## The Ecto adapter diff --git a/changelog.d/sp-02x.md b/changelog.d/sp-02x.md deleted file mode 100644 index 03724af..0000000 --- a/changelog.d/sp-02x.md +++ /dev/null @@ -1,22 +0,0 @@ -# sp-02x - -## Added - -- `use StatifierPersistence.Ecto`: compile-time configuration on the host's - module (`repo:`, `key:`, `table_prefix:`, `tables:`, `prefix:`) that - defines `Chart`, `Position`, and `Run` schema modules and exposes the - resolved config via `__statifier_persistence__/1`. Requires the optional - `ecto_sql` dependency. -- `StatifierPersistence.Ecto.KeyGenerator`: the behaviour a surrogate-key - scheme implements, with `:uxid` (default), `:uuid` (UUIDv7), `:bigserial`, - and `{module, opts}` resolved through `resolve/1`. -- `StatifierPersistence.Ecto.Migrations`: the versioned migrations helper - (`up/1`, `down/1`, taking `for: HostModule` or the same literal options - `use` takes) that creates the `charts`/`positions`/`runs` tables from the - same resolved config the schemas use. - -## Changed - -- `uxid` is now a required dependency (the default key scheme works out of - the box); `ecto_sql` is an optional dependency and the package compiles - without it. diff --git a/changelog.d/sp-4an.2.1.md b/changelog.d/sp-4an.2.1.md deleted file mode 100644 index 0005a10..0000000 --- a/changelog.d/sp-4an.2.1.md +++ /dev/null @@ -1,42 +0,0 @@ -# sp-4an.2.1 - -## Added - -- Run records on the storage contract: `StatifierPersistence.Storage.Adapter` - gains `insert_run/2`, `fetch_run/2`, and `update_run/2` callbacks with - `run_record`/`run_status` types and the `:run_exists` / `:run_not_found` - error arms; `StatifierPersistence.Storage.InMemory` implements them. -- Guarded run access on the facade: `StatifierPersistence.Storage.insert_run/5`, - `update_run/5`, `fetch_run/2`, and `load_run_position/3` (identity-guarded, - with the `:run_position_missing` arm for a run persisted without a - position). -- Run-record conformance tests in - `StatifierPersistence.Testing.StorageConformance`, so downstream adapters - inherit the same contract checks. -- The run lifecycle as a library: `StatifierPersistence.Runs.create/4` and - `step/5` drive the load -> re-stamp -> step -> execute -> persist loop - over durable run records, handing effects to a host-supplied - `StatifierPersistence.Executor` (behaviour or arity-2 fun) and returning - the host-facing `StatifierPersistence.Run` struct; events to a terminal - run come back as `{:discarded, run}`. -- Failure semantics on the loop: executor failures on actionable effects - re-enter the chart as `error.communication` events (single wave per step, - observational failures discarded); effect execution is at-least-once, with - a failed persist re-driving the same event and re-emitting the same - effects under identical deterministic keys; budget exhaustion persists a - `:failed` run (position untouched) and returns - `{:error, {:budget_exhausted, payload}}`. -- `StatifierPersistence.Runs.fail/4`, the host-driven abandonment: marks an - active run `:failed` with a reason, leaves the stored position untouched, - and discards on a terminal run - backed by the new status-only writer - `StatifierPersistence.Storage.update_run_status/4`. -- Pluggable per-run serialization: the `StatifierPersistence.Serialization` - behaviour (`with_run/3`), selected per lifecycle call with - `serialization: {module, config}` on `Runs.create/4`, `step/5`, and - `fail/4`. The default strategy, - `StatifierPersistence.Serialization.AdapterLock`, delegates to the new - optional adapter callback - `StatifierPersistence.Storage.Adapter.lock_run/3` (implemented by - `InMemory`, conformance-tested when exported) and refuses with - `{:error, {:serialization, :not_supported}}` when the adapter does not - export it. diff --git a/changelog.d/sp-4an.3.1.md b/changelog.d/sp-4an.3.1.md deleted file mode 100644 index 6623fc2..0000000 --- a/changelog.d/sp-4an.3.1.md +++ /dev/null @@ -1,17 +0,0 @@ -# sp-4an.3.1 - -## Added - -- `StatifierPersistence.Storage.Ecto`: the Postgres storage adapter over - the schemas a host generates with `use StatifierPersistence.Ecto` - (`Storage.new(Storage.Ecto, persistence: MyApp.Persistence)`). Passes - the same conformance suite as the in-memory reference adapter; engine - identities stored verbatim; `:run_exists` enforced atomically by the - unique index. -- `Storage.Ecto.isolate/1`: with `sandbox: true`, wraps each test in its - own `Ecto.Adapters.SQL.Sandbox` checkout - the hook host test suites - (and this package's conformance suite) isolate through. -- `Storage.Ecto.lock_run/3`: per-run mutual exclusion as a - transaction-scoped `pg_advisory_xact_lock` plus a `SELECT ... FOR - UPDATE` row lock (ADR-0004 as amended), consumed by - `Serialization.AdapterLock`. diff --git a/mix.exs b/mix.exs index e486ce4..b43eb34 100644 --- a/mix.exs +++ b/mix.exs @@ -15,6 +15,8 @@ defmodule StatifierPersistence.MixProject do name: "StatifierPersistence", description: "Durable stepper and storage adapters for Statifier", source_url: @source_url, + docs: docs(), + package: package(), test_coverage: [tool: ExCoveralls], dialyzer: [plt_add_apps: [:ex_unit]], preferred_cli_env: [ @@ -34,6 +36,43 @@ defmodule StatifierPersistence.MixProject do defp elixirc_paths(:test), do: ["lib", "test/support"] defp elixirc_paths(_), do: ["lib"] + # Hexdocs configuration. These paths are read off the publisher's disk at + # `mix docs` time and need no entry in package()'s files: list - the docs + # tarball hexdocs hosts is built separately from the package tarball + # `mix deps.get` fetches. + defp docs do + [ + name: "StatifierPersistence", + source_ref: "v#{@version}", + canonical: "https://hexdocs.pm/statifier_persistence", + source_url: @source_url, + main: "readme", + extras: + [ + "README.md", + "CHANGELOG.md", + "docs/restart-demo.md", + {"docs/adr/README.md", [title: "Architecture Decision Records", filename: "adr-index"]} + ] ++ Enum.sort(Path.wildcard("docs/adr/0*.md")), + groups_for_extras: [ + Guides: ~r{docs/(?!adr)}, + "Architecture Decision Records": ~r{docs/adr} + ] + ] + end + + defp package do + [ + name: "statifier_persistence", + licenses: ["MIT"], + files: ~w(lib mix.exs README.md LICENSE CHANGELOG.md), + links: %{ + "GitHub" => @source_url, + "Changelog" => "#{@source_url}/blob/main/CHANGELOG.md" + } + ] + end + defp deps do [ statifier_dep(), @@ -50,11 +89,6 @@ defmodule StatifierPersistence.MixProject do ] end - # Statifier is not on Hex - it has no package/0 and no tags - so the default - # is a git dep whose SHA mix.lock pins. Note the consequence: Hex refuses to - # publish a package that carries a git dependency, so this package cannot - # ship until statifier is published. That is upstream's call, not ours. - # # The Ecto layer's dependency shape (ecto_sql optional here vs a separate # statifier_ecto package, uxid required, postgrex test-only) is decided in # ADR-0005 - see docs/adr/0005-ecto-in-package-and-postgres-test-harness.md. @@ -64,7 +98,7 @@ defmodule StatifierPersistence.MixProject do # so the override never lands in a commit by accident. defp statifier_dep do case System.get_env("STATIFIER_PATH") do - nil -> {:statifier, github: "riddler/statifier-ex", branch: "main"} + nil -> {:statifier, "~> 2.0"} path -> {:statifier, path: path, override: true} end end diff --git a/mix.lock b/mix.lock index 47c1d6d..e09433f 100644 --- a/mix.lock +++ b/mix.lock @@ -20,7 +20,7 @@ "postgrex": {:hex, :postgrex, "0.22.4", "d271f595dfd25230b6398354e19d17bb5e2d20130fd2d9bdca7e15f125d43552", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "4aae45a2d60e35b04eea2602440be152fae332901f1fc7a60fc7cb7f0f9a9c5a"}, "predicator": {:hex, :predicator, "9.0.0", "c15685ce74e249195df9bbaedf7593c50339ac75b8700a3b13f56cfbcc837ff7", [:mix], [], "hexpm", "8e31739e3d448f9f5d91eb716498a0dfe26f3b27c8bcf96bc6b2ce93c0087e95"}, "saxy": {:hex, :saxy, "1.6.1", "742eff28f553c066d0b54e84662dbf384a1d1f38595472ed15f6e0a33038bbe1", [:mix], [], "hexpm", "8989d504424ba29460a61950f8968380651413fa05e63b6118084db057da1a6b"}, - "statifier": {:git, "https://github.com/riddler/statifier-ex.git", "68b814aad5ae0875881a4af05eefbaa81846a7ea", [branch: "main"]}, + "statifier": {:hex, :statifier, "2.0.0", "826cd183113f83cb91f90f41b9e711b7df735219783d996e8ff4a48a40d96c93", [:mix], [{:predicator, "~> 9.0", [hex: :predicator, repo: "hexpm", optional: false]}, {:saxy, "~> 1.6", [hex: :saxy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8026d01db517058c01cf289f428e9a5c3c4c52ae092ddf698aefd5c4be263a50"}, "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, "uxid": {:hex, :uxid, "2.9.0", "e61508fa4d0f997995fd3958d0033acceb70f23e5ec85ea3144485b8b9683c61", [:mix], [{:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "af4a43db3e9f25b7b182eb1870c712224e9a205f39676d98e6cfaaf3fe6029c1"}, }