Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
22 changes: 0 additions & 22 deletions changelog.d/sp-02x.md

This file was deleted.

42 changes: 0 additions & 42 deletions changelog.d/sp-4an.2.1.md

This file was deleted.

17 changes: 0 additions & 17 deletions changelog.d/sp-4an.3.1.md

This file was deleted.

46 changes: 40 additions & 6 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -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(),
Expand All @@ -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.
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
}
Loading