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
21 changes: 21 additions & 0 deletions .credo.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
%{
configs: [
%{
name: "default",
files: %{
included: ["{mix,.formatter,.credo}.exs", "lib/**/*.ex", "test/**/*.exs"],
excluded: [~r"/_build/", ~r"/deps/"]
},
strict: true,
checks: [
{Credo.Check.Refactor.Apply, false},
{Credo.Check.Refactor.CondStatements, false},
{Credo.Check.Refactor.CyclomaticComplexity, false},
{Credo.Check.Refactor.FilterFilter, false},
{Credo.Check.Refactor.NegatedConditionsWithElse, false},
{Credo.Check.Refactor.Nesting, false},
{Credo.Check.Refactor.RedundantWithClauseResult, false}
]
}
]
}
2 changes: 1 addition & 1 deletion .formatter.exs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Used by "mix format"
[
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
inputs: ["{mix,.formatter,.credo}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]
44 changes: 44 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: CI

on:
pull_request:
push:
branches:
- main
- master

jobs:
ci:
name: Elixir CI
runs-on: ubuntu-24.04

steps:
- name: Checkout source code
uses: actions/checkout@v5

- name: Set up Elixir and OTP
uses: erlef/setup-beam@v1
with:
elixir-version: "1.19.5"
otp-version: "28.3"

- name: Cache Mix dependencies and build
uses: actions/cache@v5
with:
path: |
deps
_build
key: ${{ runner.os }}-mix-${{ hashFiles('mix.lock') }}
restore-keys: |
${{ runner.os }}-mix-

- name: Install Hex and Rebar
run: |
mix local.hex --force
mix local.rebar --force

- name: Install dependencies
run: mix deps.get

- name: Run CI test script
run: ./ci-tests.sh
69 changes: 69 additions & 0 deletions .github/workflows/hex_publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
name: Hex Publish

on:
push:
tags:
- "v*"

jobs:
publish:
name: Publish Hex package
runs-on: ubuntu-24.04
permissions:
contents: read
env:
HEX_API_KEY: ${{ secrets.HEX_API_KEY }}

steps:
- name: Checkout source code
uses: actions/checkout@v5

- name: Set up Elixir and OTP
uses: erlef/setup-beam@v1
with:
elixir-version: "1.19.5"
otp-version: "28.3"

- name: Cache Mix dependencies and build
uses: actions/cache@v5
with:
path: |
deps
_build
key: ${{ runner.os }}-mix-${{ hashFiles('mix.lock') }}
restore-keys: |
${{ runner.os }}-mix-

- name: Install Hex and Rebar
run: |
mix local.hex --force
mix local.rebar --force

- name: Install dependencies
run: mix deps.get

- name: Verify tag matches mix version
shell: bash
run: |
tag_version="${GITHUB_REF_NAME#v}"
project_version="$(mix run -e 'IO.write(Mix.Project.config()[:version])')"

if [ "$tag_version" != "$project_version" ]; then
echo "Tag version $tag_version does not match mix version $project_version" >&2
exit 1
fi

- name: Run CI test script
run: ./ci-tests.sh

- name: Build docs
run: mix docs

- name: Verify Hex package contents
run: mix hex.build --unpack

- name: Publish package
run: mix hex.publish --yes

- name: Publish docs
run: mix hex.publish docs --yes
76 changes: 37 additions & 39 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
# Solve Architecture
# Solve architecture

Solve is a controller-graph runtime built from one coordinating `Solve` process, a set of
controller `GenServer`s, and optional collection sources that materialize ordered child sets.

The `Solve` app process owns graph validation, controller lifecycle, dependency reconciliation,
source and target exposed-state caching, and external subscriber tracking. Concrete controller
instances own their internal state and expose plain-map public views derived from `expose/3`.
source- and target-level exposed-state caching, and external subscriber tracking. Controller
instances own their internal state and expose plain-map public views through `expose/3`.

## Core Model
## Core model

### Sources And Targets
### Sources and targets

Solve distinguishes between static source names and concrete runtime targets.

Expand All @@ -19,9 +19,9 @@ Solve distinguishes between static source names and concrete runtime targets.
- a collection source itself is virtual; it does not own a controller pid

The dependency graph is static and source-level. Runtime lifecycle, subscriptions, and dispatch can
address either source names or concrete targets.
operate on either source names or concrete targets.

### Solve App Runtime
### Solve app runtime

Each `use Solve` module starts a single app `GenServer` that:

Expand All @@ -32,7 +32,7 @@ Each `use Solve` module starts a single app `GenServer` that:
- tracks external subscribers per source or target
- reconciles dependents when upstream state, params, or collection membership change

### Controller Graph
### Controller graph

The app module defines `controllers/0` with `controller!/1` specs. Each spec declares:

Expand All @@ -47,7 +47,7 @@ The app module defines `controllers/0` with `controller!/1` specs. Each spec dec
Inside a `use Solve` module, callback functions can call bare `dispatch/2` or `dispatch/3`.
That implicit app resolution is only guaranteed while a controller event handler is executing.

Dependency bindings are normalized into source-level graph edges plus local dependency keys.
Solve normalizes dependency bindings into source-level graph edges plus local dependency keys.

Examples:

Expand All @@ -73,7 +73,7 @@ Collection sources are different: Solve materializes them as `%Solve.Collection{
the exposed state of their child targets. The child controllers themselves are ordinary controllers
and do not know they came from a collection source.

### Exposed State
### Exposed state

Solve treats exposed state as the shared boundary between processes.

Expand All @@ -100,7 +100,7 @@ This lets the same controller broadcast turn into:
- a `:replace` dependency patch for single bindings
- `:collection_put`, `:collection_delete`, or `:collection_reorder` patches for collection bindings

## Graph Compilation
## Graph compilation

Graph validation happens on app boot, before any controllers start.

Expand All @@ -123,11 +123,11 @@ The compiled graph produces:

This gives Solve a stable source-level dependency order plus fast direct-dependent lookup.

## Controller Lifecycle
## Controller lifecycle

On boot, Solve walks the source graph in topological order and reconciles each source.

### Singleton Sources
### Singleton sources

For a singleton source, the runtime:

Expand All @@ -147,7 +147,7 @@ Params control existence:
Replacement is start-new-then-stop-old. The new controller is registered before the old one is
shut down, which avoids a gap in availability.

### Collection Sources
### Collection sources

For a collection source, the runtime:

Expand All @@ -162,9 +162,9 @@ For a collection source, the runtime:
Collected child replacement is params-based for a given `id`. If only collected callbacks change,
Solve keeps the existing child pid and updates its callbacks in place.

## Dependency Propagation
## Dependency propagation

### Direct Encoded Subscriptions
### Direct encoded subscriptions

Controllers subscribe directly to their upstream dependencies when they start.

Expand All @@ -176,16 +176,16 @@ Binding kinds matter:
- a filtered collection binding stores a `%Solve.Collection{}` and subscribes only to child
targets whose current `{id, item}` match the filter

Controllers still broadcast directly. The difference is that subscribers now carry encoder
functions, so a broadcast can be transformed before delivery.
Controllers still broadcast directly. Subscribers now carry encoder functions, so a broadcast can
be transformed before delivery.

Examples:

- single binding encoder -> `%Solve.DependencyUpdate{op: :replace, ...}`
- collection binding encoder -> `%Solve.DependencyUpdate{op: :collection_put, ...}`
- filtered collection binding encoder -> either `:collection_put` or `:collection_delete`

### Solve App Responsibilities
### Solve app responsibilities

The Solve app also subscribes to every running singleton and collected child. That lets it:

Expand All @@ -194,10 +194,9 @@ The Solve app also subscribes to every running singleton and collected child. Th
- decide whether direct dependents should start, stop, stay running, or be replaced
- add or remove dependency subscriptions when collection membership or filters change

This split keeps state propagation direct while still letting the app process stay in control of
lifecycle decisions.
This keeps state propagation direct while leaving lifecycle decisions with the app process.

## External Interaction APIs
## External interaction APIs

### `Solve.subscribe/3`

Expand All @@ -222,7 +221,7 @@ current controller pid for that target.
- if the target is running, the event is forwarded to it
- if the target is stopped, unknown, or a collection source atom, dispatch is a silent no-op

### Introspection Helpers
### Introspection helpers

Solve also exposes:

Expand All @@ -233,13 +232,13 @@ Solve also exposes:

## Solve.Lookup

`Solve.Lookup` is a process-local facade over `Solve.subscribe/3` and `Solve.dispatch/4`.
`Solve.Lookup` is a process-local wrapper and cache around `Solve.subscribe/3`
and `Solve.dispatch/4`.

In practice, the most common usage is render-driven UI code. See
`examples/emerge_lookup_example.md` for that style and `examples/counter_lookup_example.md` for the
smaller non-UI variant.
The README covers the most common public usage patterns, including UI code and
ordinary long-running processes.

It caches three shapes:
It stores three lookup shapes:

- singleton item lookups via `solve(app, :counter)`
- collected child item lookups via `solve(app, {:column, 1})`
Expand All @@ -249,10 +248,10 @@ Item lookups are augmented with `:events_` direct event tuples. Collection looku
`%Solve.Collection{}` whose items are augmented item maps. The collection wrapper itself has no
events.

`handle_message/1` refreshes the process-local cache and returns updates grouped by app as
`handle_message/1` refreshes the local cache and returns updates grouped by app as
`%Solve.Lookup.Updated{refs, collections}`.

### Auto Mode
### Auto mode

`use Solve.Lookup` defaults to `handle_info: :auto`.

Expand All @@ -263,13 +262,13 @@ Injected `handle_info/2` clauses:
- refresh the local cache through `handle_message/1`
- call `handle_solve_updated/2` with `%Solve.Lookup.Updated{refs, collections}`

### Manual Mode
### Manual mode

With `handle_info: :manual`, no `handle_info/2` clauses are injected. The caller matches
`%Solve.Message{}` itself, calls `handle_message/1`, and decides what to do with the returned map
of updated refs and collections.

## Message Shapes
## Message shapes

Singleton or child updates use `%Solve.Update{}`:

Expand Down Expand Up @@ -324,7 +323,7 @@ The runtime depends on a few fixed rules:
- dispatch to unknown or stopped targets is a no-op
- undeclared controller events are logged and discarded

## Typical Flows
## Typical flows

### Boot

Expand All @@ -333,30 +332,30 @@ The runtime depends on a few fixed rules:
3. Running targets subscribe to their dependency targets.
4. Solve subscribes to each running target and caches both target and source exposed state.

### Event Dispatch
### Event dispatch

1. A process either calls `Solve.dispatch/4`, sends a deferred dispatch envelope, or sends a direct
`{pid, {:solve_event, ...}}` tuple produced by `Solve.Lookup`.
2. The event reaches the current singleton or collected-child controller.
3. The controller updates internal state and recomputes `expose/3`.
4. If the exposed map changed, the controller broadcasts an update envelope.

### Upstream State Change
### Upstream state change

1. An upstream singleton or collected child broadcasts a new exposed map.
2. Dependent controllers receive the encoded dependency update directly.
3. The Solve app refreshes its target cache and, if needed, its source `%Solve.Collection{}`.
4. Solve reconciles direct dependents to decide whether to keep, stop, start, replace, attach, or detach subscriptions.

### Collection Reconcile
### Collection reconcile

1. A collection source re-runs `collect/1` because its upstream state changed.
2. Solve diffs ordered ids against the existing materialized collection.
3. Solve starts, stops, or replaces child targets like `{:column, id}`.
4. Solve rebuilds the source `%Solve.Collection{}` and notifies external collection subscribers.
5. Solve reevaluates collection bindings in dependents and adds or removes child subscriptions.

### Crash And Restart
### Crash and restart

1. A controller target exits unexpectedly.
2. Solve marks that target stopped and notifies external subscribers with an update carrying `nil`.
Expand All @@ -365,5 +364,4 @@ The runtime depends on a few fixed rules:
5. Solve attempts restart within a bounded retry budget.
6. If the restart budget is exhausted, the Solve app stops.

For public usage examples, see `README.md`, `examples/emerge_lookup_example.md`, and
`examples/counter_lookup_example.md`.
For public usage examples, see `README.md`.
Loading
Loading