Skip to content

Commit 8bc06b4

Browse files
Merge pull request #176 from diffo-dev/0.4.1
release 0.4.1
2 parents 150617e + 06f44ab commit 8bc06b4

20 files changed

Lines changed: 448 additions & 156 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
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

lib/diffo/provider/components/characteristic/extension.ex

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,18 @@
33
# SPDX-License-Identifier: MIT
44

55
defmodule Diffo.Provider.Characteristic.Extension do
6-
@moduledoc "Marker extension identifying a module as a valid characteristic resource."
7-
use Spark.Dsl.Extension, sections: []
6+
@moduledoc """
7+
Marker extension identifying a module as a valid characteristic resource.
8+
9+
Also synthesises default `:create` and `:update` actions on
10+
`BaseCharacteristic`-derived resources from the resource's declared public
11+
attributes — `:create` accepts `[:name | <public_attrs>]` with
12+
`:instance_id` / `:feature_id` arguments and corresponding
13+
`manage_relationship` changes; `:update` accepts `<public_attrs>` only.
14+
Consumers may declare their own `:create` / `:update` actions to override
15+
the synthesised ones.
16+
"""
17+
use Spark.Dsl.Extension,
18+
sections: [],
19+
transformers: [Diffo.Provider.Characteristic.Extension.Transformers.GenerateActions]
820
end

0 commit comments

Comments
 (0)