chore: add some docs and rename things

This commit is contained in:
Zach Daniel 2023-09-29 15:56:12 -04:00
parent 1fe4a43ff8
commit fbaed4493e
10 changed files with 242 additions and 231 deletions

2
.gitignore vendored
View file

@ -20,7 +20,7 @@ erl_crash.dump
*.ez
# Ignore package tarball (built via "mix hex.build").
ash_event_source-*.tar
ash_events-*.tar
# Temporary files, for example, from tests.
/tmp/

View file

@ -1,20 +1,28 @@
# AshEventSource
# AshEvents
**TODO: Add description**
A fledgeling Ash extension for transforming Ash resources to use an event oriented architecture. This is still an experiment, it only supports create actions (but could be made to support updates and destroys without much trouble).
## Installation
Caveats:
If [available in Hex](https://hex.pm/docs/publish), the package can be installed
by adding `ash_event_source` to your list of dependencies in `mix.exs`:
* We aren't storing the actor in any way. We would need to store actor information to perform authorization.
* the event_driven version is not really distinguishable from `ash_paper_trail` except that it has fewer features and writes to a single events resource.
* if you want to use this, you would have to do work to get it ready for your cases.
Configure the style using the `style` option, for example:
```elixir
def deps do
[
{:ash_event_source, "~> 0.1.0"}
]
events do
style :event_sourced
end
```
Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc)
and published on [HexDocs](https://hexdocs.pm). Once published, the docs can
be found at <https://hexdocs.pm/ash_event_source>.
The default is `:event_driven`, and generally means there is nothing to do
except integrate this extension.
## Event Driven
Event driven architecture is relatively simple. We encode the inputs to the action into an event and commit that event alongside the performance of the action, transactionally.
## Event Sourced
With event sourced, things change quite a bit. Instead of storing the event and performing the action, we only store the event, and it is *your responsibility* to take each event, perform the action it refers to and mark it as processed, in whatever way you see fit.

View file

@ -1,20 +0,0 @@
defmodule AshEventSource do
@event_source %Spark.Dsl.Section{
name: :event_source,
schema: [
event_resource: [
type: {:behaviour, Ash.Resource},
required: true,
doc: "The resource to use to store events."
]
]
}
@sections [@event_source]
@transformers [
AshEventSource.Transformers.RewriteActions
]
use Spark.Dsl.Extension, transformers: @transformers, sections: @sections
end

25
lib/ash_events.ex Normal file
View file

@ -0,0 +1,25 @@
defmodule AshEvents do
@events %Spark.Dsl.Section{
name: :events,
schema: [
event_resource: [
type: {:behaviour, Ash.Resource},
required: true,
doc: "The resource to use to store events."
],
style: [
type: {:one_of, [:event_sourced, :event_driven]},
default: :event_driven,
doc: "Which style of event architecture you want. See the getting started guide for more."
]
]
}
@sections [@events]
@transformers [
AshEvents.Transformers.RewriteActions
]
use Spark.Dsl.Extension, transformers: @transformers, sections: @sections
end

View file

@ -1,3 +1,3 @@
defmodule AshEventSource.Info do
use Spark.InfoGenerator, extension: AshEventSource, sections: [:event_source]
defmodule AshEvents.Info do
use Spark.InfoGenerator, extension: AshEvents, sections: [:events]
end

View file

@ -1,44 +1,48 @@
defmodule AshEventSource.PersistEvent do
defmodule AshEvents.PersistEvent do
def run(input, opts, context) do
event =
opts[:event_resource]
|> Ash.Changeset.for_create(:create, %{input: input.params, resource: input.resource, action: opts[:action]}, actor: Ash.context_to_opts(context))
|> input.api.create!()
action = Ash.Resource.Info.action(input.resource, opts[:action])
event.resource
|> Ash.Changeset.for_create(event.action, event.input)
|> input.api.create!()
if action.type == :create do
case AshEvents.Info.events_style!(input.resource) do
:event_sourced ->
opts[:event_resource]
|> Ash.Changeset.for_create(
:create,
%{
input: input.params,
resource: input.resource,
action: opts[:action],
processed: true
},
actor: Ash.context_to_opts(context)
)
|> input.api.create!()
event =
event
|> Ash.Changeset.for_update(:process)
|> input.api.update!()
{:ok, :success}
:event_driven ->
event =
opts[:event_resource]
|> Ash.Changeset.for_create(
:create,
%{
input: input.params,
resource: input.resource,
action: opts[:action],
processed: true
},
actor: Ash.context_to_opts(context)
)
|> input.api.create!()
{:ok, :success}
event.resource
|> Ash.Changeset.for_create(event.action, event.input)
|> input.api.create!()
{:ok, :success}
end
else
raise "Only create actions are currently supported"
end
end
# use Ash.Resource.ManualCreate
# use Ash.Resource.ManualUpdate
# @impl Ash.Resource.ManualCreate
# def create(changeset, opts, context) do
# input = changeset.input
# # This is a hack, need to figure this out
# Ash.Changeset.apply_attributes(changeset, force?: true)
# end
# @impl Ash.Resource.ManualUpdate
# def update(changeset, opts, context) do
# input = changeset.input
# opts[:resource]
# |> Ash.Changeset.for_create(:create, %{input: input}, actor: Ash.context_to_opts(context))
# |> changeset.api.create!()
# # This is a hack, need to figure this out
# Ash.Changeset.apply_attributes(changeset, force?: true)
# end
end

View file

@ -1,11 +1,11 @@
defmodule AshEventSource.Transformers.RewriteActions do
defmodule AshEvents.Transformers.RewriteActions do
@moduledoc "Rewrite each create, update and destroy action into event sourced actions"
use Spark.Dsl.Transformer
def after?(_), do: true
def transform(dsl) do
event_resource = AshEventSource.Info.event_source_event_resource!(dsl)
event_resource = AshEvents.Info.events_event_resource!(dsl)
dsl
|> Ash.Resource.Info.actions()
@ -33,7 +33,7 @@ defmodule AshEventSource.Transformers.RewriteActions do
arguments =
action.arguments
|> Enum.concat(arguments)
|> Enum.uniq_by(&(&1.name))
|> Enum.uniq_by(& &1.name)
action = %Ash.Resource.Actions.Action{
name: action.name,
@ -41,7 +41,7 @@ defmodule AshEventSource.Transformers.RewriteActions do
returns: :atom,
constraints: [one_of: [:success, :failure]],
arguments: arguments,
run: {AshEventSource.PersistEvent, [event_resource: event_resource, action: new_name]},
run: {AshEvents.PersistEvent, [event_resource: event_resource, action: new_name]},
transaction?: true
}

View file

@ -1,9 +1,9 @@
defmodule AshEventSource.MixProject do
defmodule AshEvents.MixProject do
use Mix.Project
def project do
[
app: :ash_event_source,
app: :ash_events,
version: "0.1.0",
elixir: "~> 1.15",
start_permanent: Mix.env() == :prod,

View file

@ -1,153 +0,0 @@
defmodule AshEventSourceTest do
use ExUnit.Case
require Ash.Query
defmodule Api do
use Ash.Api
resources do
allow_unregistered?(true)
end
end
defmodule Event do
use Ash.Resource,
data_layer: Ash.DataLayer.Ets
actions do
defaults [:read, :create, :update, :destroy]
update :process do
change set_attribute(:processed, true)
end
end
ets do
private?(true)
end
attributes do
uuid_primary_key(:id)
attribute :input, :map, allow_nil?: false
attribute :resource, :atom, allow_nil?: false
attribute :action, :atom, allow_nil?: false
attribute :processed, :boolean, allow_nil?: false, default: false
attribute :timestamp, :utc_datetime_usec do
default &DateTime.utc_now/0
allow_nil? false
writable? false
end
end
end
defmodule Profile do
use Ash.Resource,
data_layer: Ash.DataLayer.Ets,
extensions: [AshEventSource]
event_source do
event_resource(Event)
end
actions do
defaults [:create, :read, :update, :destroy]
end
ets do
private?(true)
end
attributes do
uuid_primary_key :id
attribute :bio, :string
end
relationships do
belongs_to :user, AshEventSourceTest.User do
allow_nil? false
attribute_writable? true
end
end
code_interface do
define_for Api
define :create
end
end
defmodule User do
use Ash.Resource,
data_layer: Ash.DataLayer.Ets,
extensions: [AshEventSource]
event_source do
event_resource(Event)
end
ets do
private?(true)
end
attributes do
uuid_primary_key(:id)
attribute(:username, :string, allow_nil?: false)
end
actions do
defaults([:read, :update, :destroy])
create :create do
primary? true
change after_action(fn _changeset, result ->
Profile.create!(%{user_id: result.id, bio: "Initial Bio!"})
{:ok, result}
end)
end
end
code_interface do
define_for(Api)
define(:create)
end
relationships do
has_one :profile, Profile
end
end
test "test" do
User.create!(%{username: "fred"})
# IO.inspect("FIRST EVENT CREATED")
Api.read!(Event) |> IO.inspect()
Api.read!(User) |> IO.inspect()
Api.read!(Profile) |> IO.inspect()
# process()
# IO.inspect("FIRST EVENT PROCESSED")
# Api.read!(Event) |> IO.inspect()
# Api.read!(User) |> IO.inspect()
# Api.read!(Profile) |> IO.inspect()
# process()
# IO.inspect("SECOND EVENT PROCESSED")
# Api.read!(Event) |> IO.inspect()
# Api.read!(User) |> IO.inspect()
# Api.read!(Profile) |> IO.inspect()
end
defp process() do
Event
|> Ash.Query.sort(timestamp: :asc, id: :desc)
|> Ash.Query.filter(processed == false)
|> Api.read!()
|> Enum.each(fn event ->
event.resource
|> Ash.Changeset.for_create(event.action, event.input)
|> Api.create!()
event
|> Ash.Changeset.for_update(:process)
|> Api.update!()
end)
end
end

147
test/ash_events_test.exs Normal file
View file

@ -0,0 +1,147 @@
defmodule AshEventsTest do
use ExUnit.Case
require Ash.Query
defmodule Api do
use Ash.Api
resources do
allow_unregistered?(true)
end
end
defmodule Event do
use Ash.Resource,
data_layer: Ash.DataLayer.Ets
actions do
defaults([:read, :create, :update, :destroy])
update :process do
change(set_attribute(:processed, true))
end
end
ets do
private?(true)
end
attributes do
uuid_primary_key(:id)
attribute(:input, :map, allow_nil?: false)
attribute(:resource, :atom, allow_nil?: false)
attribute(:action, :atom, allow_nil?: false)
attribute(:processed, :boolean, allow_nil?: false, default: false)
attribute :timestamp, :utc_datetime_usec do
default(&DateTime.utc_now/0)
allow_nil?(false)
writable?(false)
end
end
end
defmodule Profile do
use Ash.Resource,
data_layer: Ash.DataLayer.Ets,
extensions: [AshEvents]
events do
event_resource(Event)
end
actions do
defaults([:create, :read, :update, :destroy])
end
ets do
private?(true)
end
attributes do
uuid_primary_key(:id)
attribute(:bio, :string)
end
relationships do
belongs_to :user, AshEventsTest.User do
allow_nil?(false)
attribute_writable?(true)
end
end
code_interface do
define_for(Api)
define(:create)
end
end
defmodule User do
use Ash.Resource,
data_layer: Ash.DataLayer.Ets,
extensions: [AshEvents]
events do
event_resource(Event)
end
ets do
private?(true)
end
attributes do
uuid_primary_key(:id)
attribute(:username, :string, allow_nil?: false)
end
actions do
defaults([:read, :update, :destroy])
create :create do
primary?(true)
change(
after_action(fn _changeset, result ->
Profile.create!(%{user_id: result.id, bio: "Initial Bio!"})
{:ok, result}
end)
)
end
end
code_interface do
define_for(Api)
define(:create)
end
relationships do
has_one(:profile, Profile)
end
end
test "test" do
assert [] = Api.read!(Event)
User.create!(%{username: "fred"})
assert [_, _] = Api.read!(Event)
assert [_] = Api.read!(User)
assert [_] = Api.read!(Profile)
end
# defp process() do
# Event
# |> Ash.Query.sort(timestamp: :asc, id: :desc)
# |> Ash.Query.filter(processed == false)
# |> Api.read!()
# |> Enum.each(fn event ->
# event.resource
# |> Ash.Changeset.for_create(event.action, event.input)
# |> Api.create!()
# event
# |> Ash.Changeset.for_update(:process)
# |> Api.update!()
# end)
# end
end