Skip to content

Commit 901ba29

Browse files
Merge pull request #177 from diffo-dev/dev
release 0.4.1
2 parents 4578495 + 8bc06b4 commit 901ba29

22 files changed

Lines changed: 597 additions & 168 deletions

File tree

AGENTS.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -331,8 +331,9 @@ not. Add any useful hypotheses as a follow-up comment on the issue, then leave i
331331
- Using `characteristic :name, Diffo.Provider.AssignableCharacteristic` for pools — use `pools do / pool :name, :thing / end` instead.
332332
- Using the removed `AssignableValue` TypedStruct — it no longer exists; use `pools do`.
333333
- Calling `Assigner.assign/4` when a `pools do` declaration exists — prefer `Assigner.assign/3` which looks up the thing automatically.
334-
- Forgetting to call `Pool.update_pools/3` in `:define` actions when the resource has `pools do` — pool bounds (`first`, `last`, `algorithm`) are set here.
335-
- Calling `Assigner.assign/3` on an instance that is not in the correct lifecycle state — the assigner enforces: resource instances must have `resource_state: :operating`; service instances must have `service_state: :active` or `:inactive`. Lifecycle state transitions are an internal domain concern managed by the provider; assignment actions are external-facing. Future: consumer reads may filter out non-`:operating` resources entirely.
334+
- Hand-writing the `:define` / `:relate` / `:assign_*` after-action plumbing — use `Diffo.Provider.Changes.Define`, `Diffo.Provider.Changes.Relate`, and `{Diffo.Provider.Changes.Assign, pool: :name}` (since 0.4.1). The change modules thread `Characteristic.update_all/3`, `Pool.update_pools/3`, `Relationship.relate_instance/2` and `Assigner.assign/3` together and reload via the resource's primary `:read` action.
335+
- Hand-writing the `:create` / `:update` accept lists on a `BaseCharacteristic`-derived resource — they are synthesised from the resource's public attributes (since 0.4.1). Declare your own only when you need a narrower accept list.
336+
- Calling `Assigner.assign/3` on an instance that is not in the correct lifecycle state — the assigner enforces: resource instances must have `resource_state` of `:installing` or `:operating`; service instances must have `service_state` of `:feasibilityChecked`, `:reserved`, `:inactive`, `:active`, or `:suspended` (since 0.4.1). The full lists are exposed via `Assigner.assignable_resource_states/0` and `Assigner.assignable_service_states/0`. Lifecycle state transitions are an internal domain concern managed by the provider; assignment actions are external-facing.
336337
- Wondering why `Relationship` and `AssignmentRelationship` both have an `alias` attribute with a `[:source_id, :alias]` / `[:target_id, :alias]` identity — alias is a "baby name" given to a relationship slot before (or when) the target is bound. Its full purpose becomes clear alongside the first-order expectation system (see issue #122): the expectation declares the alias for a slot it expects to be filled, and the actual relationship carries the same alias so the two can be matched. Without expectations in place, aliases look like optional metadata; with them, they are the join key between intent and fulfilment.
337338
- Using `characteristic :pool_name, Diffo.Provider.AssignedToRelationship``AssignedToRelationship` no longer exists; use `pools do / pool :name, :thing / end` instead.
338339
- Querying `Diffo.Provider.Relationship` for assignment records — assignments are stored as `Diffo.Provider.DefinedSimpleRelationship`; access them via `instance.assignments`.

CHANGELOG.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,42 @@ See [Conventional Commits](Https://conventionalcommits.org) for commit guideline
1111

1212
<!-- changelog -->
1313

14+
## [v0.4.1](https://github.com/diffo-dev/diffo/compare/v0.4.0...v0.4.1) (2026-05-22)
15+
16+
### Bug Fixes
17+
18+
* **Assigner lifecycle** (#168) — broadened the lifecycle states permitted to make assignments. Services may now assign from `:feasibilityChecked`, `:reserved`, `:inactive`, `:active`, or `:suspended` (was `:active` / `:inactive` only). Resources may now assign from `:installing` or `:operating` (was `:operating` only). `Assigner.assignable_state?/1` exposes the policy directly.
19+
20+
### Features
21+
22+
* **`Diffo.Provider.Changes.Define` / `Relate` / `Assign`** (#170) — change modules that wrap the standard after-action patterns every Instance consumer writes. Replace the hand-written `after_action` body threading `Characteristic.update_all` / `Pool.update_pools` / `Relationship.relate_instance` / `Assigner.assign` together with a one-liner:
23+
24+
```elixir
25+
update :define do
26+
argument :characteristic_value_updates, {:array, :term}
27+
change Diffo.Provider.Changes.Define
28+
end
29+
30+
update :relate do
31+
argument :relationships, {:array, :struct}
32+
change Diffo.Provider.Changes.Relate
33+
end
34+
35+
update :assign_port do
36+
argument :assignment, :struct, constraints: [instance_of: Assignment]
37+
change {Diffo.Provider.Changes.Assign, pool: :ports}
38+
end
39+
```
40+
41+
Reload happens via the resource's primary `:read` action, so no consumer-specific reader is needed.
42+
* **BaseCharacteristic auto-generated `:create` / `:update` actions** (#171) — `BaseCharacteristic`-derived resources now get default `:create` and `:update` actions synthesised from their public attributes. `:create` accepts `[:name | <public_attrs>]` with `:instance_id` / `:feature_id` arguments and `manage_relationship` changes; `:update` accepts `<public_attrs>`. Consumers may still declare their own actions to override the defaults.
43+
* **Typed characteristics and pools in Instance JSON** (#169) — `BaseInstance` now loads two new calculations (`:typed_characteristics`, `:pool_characteristics`) by default and the jason customize merges their values into the `serviceCharacteristic` / `resourceCharacteristic` array. Typed `BaseCharacteristic` records and `AssignableCharacteristic` pool records that were already present in the graph are now visible at the TMF JSON surface.
44+
45+
### Notable Changes
46+
47+
* `Diffo.Provider.Calculations.TypedCharacteristics` and `Diffo.Provider.Calculations.PoolCharacteristics` — new calc modules backing the JSON surfacing for #169.
48+
* Regression test added for #62 (characteristic update validation) — typed `BaseCharacteristic` updates now reject unknown fields and invalid types through Ash's standard changeset machinery.
49+
1450
## [v0.4.0](https://github.com/diffo-dev/diffo/compare/v0.3.0...v0.4.0) (2026-05-20)
1551

1652
### Breaking Changes

lib/diffo/provider/assigner/assigner.ex

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,26 @@ defmodule Diffo.Provider.Assigner do
1313
alias Diffo.Provider.AssignableCharacteristic
1414
alias Diffo.Provider.AssignmentRelationship
1515

16+
@assignable_resource_states [:installing, :operating]
17+
@assignable_service_states [:feasibilityChecked, :reserved, :inactive, :active, :suspended]
18+
19+
@doc """
20+
The resource lifecycle states from which an instance may make assignments.
21+
"""
22+
def assignable_resource_states, do: @assignable_resource_states
23+
24+
@doc """
25+
The service lifecycle states from which an instance may make assignments.
26+
"""
27+
def assignable_service_states, do: @assignable_service_states
28+
1629
@doc """
1730
Assign a thing using the pool declared via `pools do` on the instance module.
1831
The thing name is looked up from the pool declaration.
1932
"""
2033
def assign(result, changeset, pool_name)
2134
when is_struct(result) and is_struct(changeset, Ash.Changeset) and is_atom(pool_name) do
22-
with :ok <- check_lifecycle(result) do
35+
with :ok <- assignable_state?(result) do
2336
case result.__struct__.pool(pool_name) do
2437
nil -> {:error, "pool #{pool_name} not declared on #{result.__struct__}"}
2538
pool -> assign(result, changeset, pool_name, pool.thing)
@@ -61,17 +74,23 @@ defmodule Diffo.Provider.Assigner do
6174
end
6275
end
6376

64-
defp check_lifecycle(%{type: :resource, resource_state: state}) when state != :operating,
65-
do:
66-
{:error, "cannot assign: resource lifecycle state is #{inspect(state)}, must be :operating"}
67-
68-
defp check_lifecycle(%{type: :service, service_state: state})
69-
when state not in [:active, :inactive],
70-
do:
71-
{:error,
72-
"cannot assign: service state is #{inspect(state)}, must be :active or :inactive"}
73-
74-
defp check_lifecycle(_), do: :ok
77+
@doc """
78+
Returns `:ok` if the instance is in a lifecycle state that permits assignment,
79+
otherwise `{:error, reason}`.
80+
"""
81+
def assignable_state?(%{type: :resource, resource_state: state})
82+
when state not in @assignable_resource_states,
83+
do:
84+
{:error,
85+
"cannot assign: resource lifecycle state is #{inspect(state)}, must be one of #{inspect(@assignable_resource_states)}"}
86+
87+
def assignable_state?(%{type: :service, service_state: state})
88+
when state not in @assignable_service_states,
89+
do:
90+
{:error,
91+
"cannot assign: service state is #{inspect(state)}, must be one of #{inspect(@assignable_service_states)}"}
92+
93+
def assignable_state?(_), do: :ok
7594

7695
defp create_assignment(result, pool, thing, value, assignee_id, alias_name)
7796
when is_struct(result) and is_atom(pool) and is_atom(thing) and is_integer(value) and
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# SPDX-FileCopyrightText: 2025 diffo contributors <https://github.com/diffo-dev/diffo/graphs.contributors>
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
defmodule Diffo.Provider.Changes.Assign do
6+
@moduledoc """
7+
After-action change for the standard `:assign_*` pattern.
8+
9+
Performs an assignment for the named pool via `Assigner.assign/3`, then
10+
reloads the result via the resource's primary `:read` action.
11+
12+
## Usage
13+
14+
update :assign_port do
15+
argument :assignment, :struct, constraints: [instance_of: Assignment]
16+
change {Diffo.Provider.Changes.Assign, pool: :ports}
17+
end
18+
19+
## Options
20+
21+
- `:pool` (required) — the pool name (atom) declared via `pools do` on the
22+
consuming instance resource.
23+
"""
24+
use Ash.Resource.Change
25+
26+
require Ash.Query
27+
28+
alias Diffo.Provider.Assigner
29+
30+
@impl true
31+
def init(opts) do
32+
case Keyword.fetch(opts, :pool) do
33+
{:ok, pool} when is_atom(pool) and not is_nil(pool) ->
34+
{:ok, opts}
35+
36+
_ ->
37+
{:error, "Diffo.Provider.Changes.Assign requires a :pool atom option"}
38+
end
39+
end
40+
41+
@impl true
42+
def change(changeset, opts, _context) do
43+
pool = Keyword.fetch!(opts, :pool)
44+
45+
Ash.Changeset.after_action(changeset, fn changeset, result ->
46+
with {:ok, result} <- Assigner.assign(result, changeset, pool) do
47+
id = result.id
48+
49+
changeset.resource
50+
|> Ash.Query.for_read(:read)
51+
|> Ash.Query.filter(id == ^id)
52+
|> Ash.read_one()
53+
end
54+
end)
55+
end
56+
end
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# SPDX-FileCopyrightText: 2025 diffo contributors <https://github.com/diffo-dev/diffo/graphs.contributors>
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
defmodule Diffo.Provider.Changes.Define do
6+
@moduledoc """
7+
After-action change for the standard `:define` pattern.
8+
9+
Applies `characteristic_value_updates` to the instance's declared typed and
10+
dynamic characteristics (`Characteristic.update_all/3`) and to its declared
11+
pools (`Pool.update_pools/3`), then reloads the result via the resource's
12+
primary `:read` action.
13+
14+
## Usage
15+
16+
update :define do
17+
argument :characteristic_value_updates, {:array, :term}
18+
change Diffo.Provider.Changes.Define
19+
end
20+
21+
This replaces the hand-written `after_action` block that threads
22+
`characteristics()`, `pools()`, `Characteristic.update_all/3` and
23+
`Pool.update_pools/3` together on every consumer.
24+
"""
25+
use Ash.Resource.Change
26+
27+
require Ash.Query
28+
29+
alias Diffo.Provider.Extension.Characteristic
30+
alias Diffo.Provider.Extension.Pool
31+
32+
@impl true
33+
def change(changeset, _opts, _context) do
34+
Ash.Changeset.after_action(changeset, fn changeset, result ->
35+
module = changeset.resource
36+
37+
with {:ok, result} <- Ash.load(result, [:characteristics]),
38+
{:ok, result} <- Characteristic.update_all(result, changeset, module.characteristics()),
39+
{:ok, result} <- Pool.update_pools(result, changeset, module.pools()) do
40+
id = result.id
41+
42+
module
43+
|> Ash.Query.for_read(:read)
44+
|> Ash.Query.filter(id == ^id)
45+
|> Ash.read_one()
46+
end
47+
end)
48+
end
49+
end
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# SPDX-FileCopyrightText: 2025 diffo contributors <https://github.com/diffo-dev/diffo/graphs.contributors>
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
defmodule Diffo.Provider.Changes.Relate do
6+
@moduledoc """
7+
After-action change for the standard `:relate` pattern.
8+
9+
Creates relationships from the `:relationships` argument on the changeset via
10+
`Relationship.relate_instance/2`, then reloads the result via the resource's
11+
primary `:read` action.
12+
13+
## Usage
14+
15+
update :relate do
16+
argument :relationships, {:array, :struct}
17+
change Diffo.Provider.Changes.Relate
18+
end
19+
"""
20+
use Ash.Resource.Change
21+
22+
require Ash.Query
23+
24+
alias Diffo.Provider.Instance.Relationship
25+
26+
@impl true
27+
def change(changeset, _opts, _context) do
28+
Ash.Changeset.after_action(changeset, fn changeset, result ->
29+
with {:ok, result} <- Relationship.relate_instance(result, changeset) do
30+
id = result.id
31+
32+
changeset.resource
33+
|> Ash.Query.for_read(:read)
34+
|> Ash.Query.filter(id == ^id)
35+
|> Ash.read_one()
36+
end
37+
end)
38+
end
39+
end

lib/diffo/provider/components/base_instance.ex

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,7 @@ defmodule Diffo.Provider.BaseInstance do
235235
:features,
236236
Instance.derive_feature_list_name(record.type)
237237
)
238+
|> Instance.merge_typed_and_pool_characteristics(record)
238239
|> Util.suppress_rename(
239240
:characteristics,
240241
Instance.derive_characteristic_list_name(record.type)
@@ -688,6 +689,8 @@ defmodule Diffo.Provider.BaseInstance do
688689
:notes,
689690
:features,
690691
:characteristics,
692+
:typed_characteristics,
693+
:pool_characteristics,
691694
:places,
692695
:parties
693696
],
@@ -699,5 +702,17 @@ defmodule Diffo.Provider.BaseInstance do
699702
calculate :href, :string, Diffo.Provider.Calculations.InstanceHref do
700703
description "the inventory href of the service or resource instance"
701704
end
705+
706+
calculate :typed_characteristics,
707+
{:array, :struct},
708+
Diffo.Provider.Calculations.TypedCharacteristics do
709+
description "typed BaseCharacteristic records declared via the characteristics DSL"
710+
end
711+
712+
calculate :pool_characteristics,
713+
{:array, :struct},
714+
Diffo.Provider.Calculations.PoolCharacteristics do
715+
description "AssignableCharacteristic records declared via the pools DSL"
716+
end
702717
end
703718
end
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# SPDX-FileCopyrightText: 2025 diffo contributors <https://github.com/diffo-dev/diffo/graphs.contributors>
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
defmodule Diffo.Provider.Calculations.PoolCharacteristics do
6+
@moduledoc """
7+
Loads the `AssignableCharacteristic` pool records associated with an
8+
instance, one per `pool :name, :thing` declaration on the resource module.
9+
10+
Used by `BaseInstance` to surface pool characteristics alongside the
11+
dynamic `Diffo.Provider.Characteristic` records in the
12+
`serviceCharacteristic` / `resourceCharacteristic` JSON view (#169).
13+
"""
14+
use Ash.Resource.Calculation
15+
16+
@impl true
17+
def load(_query, _opts, _context), do: []
18+
19+
@impl true
20+
def calculate(records, _opts, _context) do
21+
Enum.map(records, fn record ->
22+
if function_exported?(record.__struct__, :pools, 0) and record.__struct__.pools() != [] do
23+
Diffo.Provider.AssignableCharacteristic
24+
|> Ash.Query.filter_input(instance_id: record.id)
25+
|> Ash.read!(domain: Diffo.Provider)
26+
else
27+
[]
28+
end
29+
end)
30+
end
31+
end
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# SPDX-FileCopyrightText: 2025 diffo contributors <https://github.com/diffo-dev/diffo/graphs.contributors>
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
defmodule Diffo.Provider.Calculations.TypedCharacteristics do
6+
@moduledoc """
7+
Loads all typed `BaseCharacteristic`-derived records associated with an
8+
instance.
9+
10+
For each `characteristic :role, ValueModule` declaration on the instance's
11+
resource module — including both singular and `{:array, ValueModule}` forms
12+
— this calculation queries `ValueModule` by `instance_id == record.id` and
13+
returns the collected records as a flat list.
14+
15+
Used by `BaseInstance` to surface typed characteristics alongside the
16+
dynamic `Diffo.Provider.Characteristic` records in the
17+
`serviceCharacteristic` / `resourceCharacteristic` JSON view (#169).
18+
"""
19+
use Ash.Resource.Calculation
20+
21+
alias Diffo.Provider.Extension.Characteristic, as: CharacteristicHelper
22+
23+
@impl true
24+
def load(_query, _opts, _context), do: []
25+
26+
@impl true
27+
def calculate(records, _opts, _context) do
28+
Enum.map(records, fn record ->
29+
record
30+
|> typed_value_modules()
31+
|> Enum.flat_map(fn module ->
32+
module
33+
|> Ash.Query.filter_input(instance_id: record.id)
34+
|> Ash.read!(domain: Diffo.Provider)
35+
end)
36+
end)
37+
end
38+
39+
defp typed_value_modules(record) do
40+
module = record.__struct__
41+
42+
if function_exported?(module, :characteristics, 0) do
43+
module.characteristics()
44+
|> Enum.map(fn %{value_type: value_type} ->
45+
case value_type do
46+
{:array, mod} -> mod
47+
mod when is_atom(mod) -> mod
48+
end
49+
end)
50+
|> Enum.uniq()
51+
|> Enum.filter(&CharacteristicHelper.typed?/1)
52+
else
53+
[]
54+
end
55+
end
56+
end

0 commit comments

Comments
 (0)