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
32 changes: 32 additions & 0 deletions artefact/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,38 @@ SPDX-License-Identifier: MIT

# Changelog

## 0.2.0 — 2026-05-05 *(breaking)*

### API shape

- All public ops now have **two variants**:
- `new/1`, `compose/3`, `combine/3`, `harmonise/4`, `graft/3` return `{:ok, %Artefact{}} | {:error, error}`.
- `new!/1`, `compose!/3`, `combine!/3`, `harmonise!/4`, `graft!/3` return `%Artefact{}` directly or raise the error struct. Behaviour matches 0.1.5's raise-everywhere — the `!` variants are the gentle migration path.
- `validate/1` shape: `:ok | {:error, %Artefact.Error.Invalid{reasons: [...]}}` (was `{:error, [reason_strings]}`).
- `validate!/1` raises `Artefact.Error.Invalid` (was `ArgumentError`).
- Closes [#23], [#25].

### Errors as structured values

- New `:splode` runtime dependency (`{:splode, "~> 0.3"}`).
- `Artefact.Error` — Splode root with two error classes (`:invalid`, `:operation`).
- `Artefact.Error.Invalid` — validation rule violations; `:reasons` field carries the list of human-readable strings.
- `Artefact.Error.Operation` — op-specific outcomes; `:op`, `:tag`, `:details` fields. See `MIGRATION.md` for the full per-op tag table.
- Errors are real Elixir exceptions — raisable by the `!` variants, pattern-matchable as struct values from the non-`!` variants, and aggregatable by Splode-using callers (e.g. UsTwo libraries).

### Module reorg (internal)

- `Artefact.Op` — implementation home for the operations.
- `Artefact.Validator` — implementation home for validation rules; surfaced via `defdelegate` from `Artefact`.
- The `Artefact` module is now a thin macro facade plus the `%Artefact{}` struct definition. Future internal refactors won't churn the consumer-visible surface.

### Migration

See [`MIGRATION.md`](MIGRATION.md) for the migration guide. TL;DR — append `!` to every op call and you're done; use the non-`!` variant + `with`/`case` if you want explicit error handling.

[#23]: https://github.com/diffo-dev/artefactory/issues/23
[#25]: https://github.com/diffo-dev/artefactory/issues/25

## 0.1.5 — 2026-05-05

- `Artefact.is_artefact?/1`, `Artefact.is_valid?/1`, `Artefact.validate/1`, `Artefact.validate!/1` — public validation API. Closes [#26], [#27]
Expand Down
176 changes: 176 additions & 0 deletions artefact/MIGRATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
<!--
SPDX-FileCopyrightText: 2026 artefactory contributors <https://github.com/diffo-dev/artefactory/graphs/contributors>
SPDX-License-Identifier: MIT
-->

# Migration: artefact 0.1.x → 0.2.0

0.2.0 reshapes the public API to be idiomatic Elixir. Operations now
return `{:ok, _}` / `{:error, _}` tuples and have `!` variants that
raise. Errors are `Splode`-typed structs that pattern-match cleanly.

## TL;DR

If you were using 0.1.x and you're happy with raise-on-error
semantics, append `!` to every op call and you're done:

```diff
- artefact = Artefact.new(title: "x", nodes: [...])
+ artefact = Artefact.new!(title: "x", nodes: [...])

- result = a |> Artefact.combine(b) |> Artefact.combine(c)
+ result = a |> Artefact.combine!(b) |> Artefact.combine!(c)
```

That's the whole migration if you don't want to use the new return
shape.

## Per-op changes

### `new/1` — now returns `{:ok, _}`

```elixir
# 0.1.x
artefact = Artefact.new(title: "x", nodes: [...])

# 0.2.0
{:ok, artefact} = Artefact.new(title: "x", nodes: [...])
# or
artefact = Artefact.new!(title: "x", nodes: [...])
```

### `compose/3`, `combine/3`, `harmonise/4`, `graft/3`

All shifted to `{:ok, _}` / `{:error, _}` returns. The `!` variants
match the old 0.1.x raise behaviour exactly.

```elixir
# 0.1.x
result =
me_knowing
|> Artefact.combine(me_valuing)
|> Artefact.combine(me_being)

# 0.2.0 — pipeline-friendly with `!`
result =
me_knowing
|> Artefact.combine!(me_valuing)
|> Artefact.combine!(me_being)

# 0.2.0 — explicit `with` for error handling
with {:ok, knowing} <- Artefact.combine(me_knowing, me_valuing),
{:ok, being} <- Artefact.combine(knowing, me_being) do
{:ok, being}
end
```

## Error shapes

Errors are now structs from the `Splode`-based `Artefact.Error.*`
namespace. Two flavours:

### `Artefact.Error.Invalid` — class `:invalid`

Validation rule violations on the produced or input artefact.

```elixir
%Artefact.Error.Invalid{reasons: ["uuid is not a valid UUIDv7"]}
```

`:reasons` is a list of human-readable strings — same shape as 0.1.5's
`validate/1` reasons, just wrapped in a struct.

### `Artefact.Error.Operation` — class `:operation`

Op-specific outcomes that prevent the op from proceeding even with
valid input. The `:tag` field discriminates the specific outcome:

| Op | `:tag` values | `:details` |
|----|---------------|------------|
| `combine` | `:no_shared_bindings` | `%{}` |
| `harmonise` | `:self_harmonise` | `%{uuid: ...}` |
| `harmonise` | `:same_base_label` | `%{base_label: ...}` |
| `graft` | `:missing_uuid` | `%{key: ...}` |
| `graft` | `:invalid_uuid` | `%{key: ..., uuid: ...}` |
| `graft` | `:invalid_labels` | `%{key: ..., labels: ...}` |
| `graft` | `:invalid_properties` | `%{key: ..., properties: ...}` |
| `graft` | `:duplicate_keys` | `%{keys: [...]}` |
| `graft` | `:unknown_rel_key` | `%{key: ...}` |
| `graft` | `:islands` | `%{keys: [...]}` |

Pattern matching on the tag is the idiomatic way:

```elixir
case Artefact.combine(heart, other) do
{:ok, result} -> result
{:error, %Artefact.Error.Operation{tag: :no_shared_bindings}} ->
Artefact.compose!(heart, other)
end
```

## Specific raise-type changes

If you were rescuing exceptions from 0.1.x:

| 0.1.x raise | 0.2.0 raise (from `!` variants) |
|-------------|---------------------------------|
| `ArgumentError` "invalid artefact: ..." | `Artefact.Error.Invalid` |
| `ArgumentError` "cannot harmonise an artefact with itself" | `Artefact.Error.Operation` (tag `:self_harmonise`) |
| `ArgumentError` "cannot harmonise artefacts with the same base_label" | `Artefact.Error.Operation` (tag `:same_base_label`) |
| `MatchError` (combine, no shared bindings) | `Artefact.Error.Operation` (tag `:no_shared_bindings`) |
| `ArgumentError` "graft: ..." | `Artefact.Error.Operation` (op `:graft`, various tags) |

`rescue` clauses should switch to the new types:

```elixir
# 0.1.x
try do
Artefact.combine(heart, other)
rescue
MatchError -> :ok
end

# 0.2.0
case Artefact.combine(heart, other) do
{:ok, _} -> :ok
{:error, _} -> :ok
end
```

## Validation API

`is_artefact?/1`, `is_valid?/1`, `validate/1`, `validate!/1` are now
delegated from `Artefact` to the new `Artefact.Validator` module —
the surface call site is unchanged. Two shape changes:

* `validate/1` — return is now `:ok` or `{:error, %Artefact.Error.Invalid{reasons: [...]}}`
(was `{:error, [reason_strings]}`).
* `validate!/1` — raises `Artefact.Error.Invalid` (was `ArgumentError`).

## New module surface

Internal modules introduced in 0.2.0:

* `Artefact.Op` — implementation home for `new`, `compose`, `combine`,
`harmonise`, `graft`. Don't depend on this directly — `Artefact` is
still the supported surface.
* `Artefact.Validator` — validation rule implementation, surfaced via
`Artefact`'s defdelegated functions.
* `Artefact.Error` — Splode root.
* `Artefact.Error.Invalid`, `Artefact.Error.Operation`,
`Artefact.Error.Unknown` — error structs.

The `Artefact` module itself becomes a thin macro facade plus the
struct definition. Future internal refactors won't require consumer
changes if you stick to `Artefact.*`.

## Dependency added

`{:splode, "~> 0.3"}` — error-class library used for the new error
structs. Adds about 1KB compiled, no transitive runtime deps beyond
Elixir core.

## Held in the commons

If your migration surfaces a sharp edge or a missing escape hatch,
file an issue at https://github.com/diffo-dev/artefactory/issues.
55 changes: 37 additions & 18 deletions artefact/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,43 +78,62 @@ Artefact.Arrows.to_json(us_two)

## Combining and Extending Artefacts

Operations come in two variants: `op/n` returns `{:ok, %Artefact{}} | {:error, error}`; `op!/n` returns the artefact directly or raises the error struct. Use `!` in pipelines or when you'd rather let exceptions propagate; use the non-`!` form when you want to handle errors explicitly.

```elixir
# compose — disjoint union, nodes remain independent
combined = Artefact.compose(a1, a2)
{:ok, combined} = Artefact.compose(a1, a2)

# combine — pipeline-friendly union; bindings auto-found via shared uuid.
# Delegates to harmonise. Raises MatchError if no shared nodes.
my_knowing
|> Artefact.combine(my_valuing)
|> Artefact.combine(my_being)
|> Artefact.combine(my_doing, title: "MeMind", description: "Mind of Me")
# Returns {:error, %Artefact.Error.Operation{tag: :no_shared_bindings}}
# if heart and other share no node uuids.
result =
my_knowing
|> Artefact.combine!(my_valuing)
|> Artefact.combine!(my_being)
|> Artefact.combine!(my_doing, title: "MeMind", description: "Mind of Me")

# harmonise — union via declared bindings.
# Lower uuid wins identity, labels are unioned, left wins on property conflict.
{:ok, bindings} = Artefact.Binding.find(a1, a2)
harmonised = Artefact.harmonise(a1, a2, bindings)
{:ok, harmonised} = Artefact.harmonise(a1, a2, bindings)

# graft — extend an existing artefact inline with new nodes and
# relationships. args matches Artefact.new's inline shape, but every
# node MUST carry :uuid (no auto-find — uuid is the binding).
# Nodes whose uuid lives in left bind to it (labels unioned, properties
# merged left-wins). Nodes with new uuids are added.
me_mind
|> Artefact.graft(
[
nodes: [
{:me, [uuid: "019ddb71-c70b-7b3e-83b1-58f4d0be2852"]}, # bind-only
{:stewardship, [labels: ["Knowing"], uuid: "019df318-698c-77d6-bc7b-ea041a019a7f"]}
result =
me_mind
|> Artefact.graft!(
[
nodes: [
{:me, [uuid: "019ddb71-c70b-7b3e-83b1-58f4d0be2852"]},
{:stewardship, [labels: ["Knowing"],
uuid: "019df318-698c-77d6-bc7b-ea041a019a7f"]}
],
relationships: [[from: :me, type: "KNOWING", to: :stewardship]]
],
relationships: [[from: :me, type: "KNOWING", to: :stewardship]]
],
title: "MeMind + Stewardship",
description: "Stewardship grafted onto MeMind."
)
title: "MeMind + Stewardship",
description: "Stewardship grafted onto MeMind."
)
```

Errors are `Splode`-typed structs — pattern-match on `Artefact.Error.Invalid` (validation-rule violations) or `Artefact.Error.Operation` (op-specific outcomes) to handle each case. See [`MIGRATION.md`](MIGRATION.md) for the full error shape table.

Provenance is recorded automatically — every artefact carries metadata describing how it was created, including the calling module and, for derived artefacts, a summary of each source.

## Validation

```elixir
Artefact.is_artefact?(value) # boolean
Artefact.is_valid?(artefact) # boolean
Artefact.validate(artefact) # :ok | {:error, %Artefact.Error.Invalid{reasons: [...]}}
Artefact.validate!(artefact) # :ok | raises Artefact.Error.Invalid
```

Every operation validates its inputs and the produced artefact, so corruption fails at the call site rather than steps downstream.

## Importing from Arrows JSON

```elixir
Expand Down
Loading
Loading