diff --git a/artefact/CHANGELOG.md b/artefact/CHANGELOG.md index c2a51cd..9d7b2b1 100644 --- a/artefact/CHANGELOG.md +++ b/artefact/CHANGELOG.md @@ -5,6 +5,23 @@ SPDX-License-Identifier: MIT # Changelog +## 0.3.0 — 2026-05-13 + +### Mermaid import + +- `Artefact.Mermaid.from_mmd!/2` — parse a Mermaid `graph` source string into an `%Artefact{}`. Accepts the round-trip format produced by `export/2` and the broader Mermaid legacy graph syntax. Supports YAML front matter titles, `accDescr:` descriptions, node label conventions (`name
labels` and `LABEL · name`), and `click id "tooltip"` node descriptions. +- `Artefact.UUID.from_name/1` — derive a deterministic UUIDv7-shaped UUID from a stable name string. Used by `from_mmd!/2` to anchor node identity on the Mermaid node id, ensuring repeated imports bind correctly via `combine!/2`. +- `export/2` updated to emit `click id "description"` tooltip lines for nodes with a `description` property — present in source, visible on hover, recovered on import. + +### Bug fix + +- `combine!/2` no longer raises `duplicate relationship ids` when the two input artefacts have disjoint relationships. `harmonise/5` in `Artefact.Op` now reindexes `rels_from_b` with an offset so ids never clash. Closes [#38](https://github.com/diffo-dev/artefactory/issues/38). + +### Tooling + +- Igniter task `mix artefact.install` — preferred installation method; wires the formatter. +- `usage-rules.md` — consumer-facing AI agent guidance, compatible with the `usage_rules` hex package ecosystem. + ## 0.2.0 — 2026-05-05 *(breaking)* ### API shape diff --git a/artefact/README.md b/artefact/README.md index eae6ce1..c4e4235 100644 --- a/artefact/README.md +++ b/artefact/README.md @@ -20,10 +20,18 @@ As we yarn we naturally exchange and create Artefacts. ## Installation +The preferred way to install Artefact is via Igniter: + +```bash +mix igniter.install artefact +``` + +Or add the dependency manually: + ```elixir def deps do [ - {:artefact, "~> 0.1"} + {:artefact, "~> 0.3"} ] end ``` diff --git a/artefact/lib/artefact/mermaid.ex b/artefact/lib/artefact/mermaid.ex index 318cda6..7479999 100644 --- a/artefact/lib/artefact/mermaid.ex +++ b/artefact/lib/artefact/mermaid.ex @@ -3,7 +3,21 @@ defmodule Artefact.Mermaid do @moduledoc """ - Derives Mermaid diagram source from an `%Artefact{}`. + Converts between `%Artefact{}` structs and Mermaid legacy `graph` source. + + Two public functions: + + - `export/2` — artefact → Mermaid string + - `from_mmd!/2` — Mermaid string → artefact + + ## Round-trip fidelity + + `export/2` followed by `from_mmd!/2` followed by `export/2` produces + identical Mermaid source. The preserved fields are: `title`, `description`, + node `name` and `description` properties, node labels, and relationship + types. See *Lossy* below for what is not preserved. + + ## Export format Uses the legacy `graph` syntax for broad renderer compatibility (GitHub, Notion, mdBook, Livebook). Nodes render as circles (`id(("..."))`) — the @@ -23,8 +37,14 @@ defmodule Artefact.Mermaid do description is omitted. Like `accTitle`, the description is screen-reader only — Mermaid does not render it visually. - Lossy: `position`, `style`, properties beyond `name`, and the artefact-level - `base_label` (collapsed into per-node labels at output time) are not represented. + Node `description` properties are emitted as `click id "description"` tooltip lines — + present in source, visible on hover, and parseable by `from_mmd!/2`. + + ## Lossy + + `position`, `style`, properties beyond `name` and `description`, and the + artefact-level `base_label` (collapsed into per-node labels at output time) + are not represented in Mermaid source and are not recovered on import. """ @directions ~w(LR RL TB BT TD)a @@ -63,10 +83,11 @@ defmodule Artefact.Mermaid do end node_lines = Enum.map(graph.nodes, &node_line(&1, base_label)) + click_lines = Enum.flat_map(graph.nodes, &click_line/1) rel_lines = Enum.map(graph.relationships, &rel_line/1) accessibility = acc_title_lines(title) ++ acc_descr_lines(description) - body = ["graph #{direction}" | accessibility ++ node_lines ++ rel_lines] + body = ["graph #{direction}" | accessibility ++ node_lines ++ click_lines ++ rel_lines] Enum.join(front_matter(title) ++ body, "\n") end @@ -156,10 +177,252 @@ defmodule Artefact.Mermaid do " #{node.id}((\"#{label_text}\"))" end + defp click_line(%Artefact.Node{id: id, properties: props}) do + case Map.get(props, "description") do + nil -> [] + desc -> [" click #{id} \"#{escape(desc)}\""] + end + end + defp rel_line(%Artefact.Relationship{type: type, from_id: from, to_id: to}) do " #{from} -->|#{escape_pipe(type)}| #{to}" end + # -- parser -- + + @doc """ + Parse a Mermaid `graph` source string into an `%Artefact{}`. + + Accepts both the round-trip format produced by `export/2` and the broader + Mermaid legacy graph syntax used by tools like Confluence and GitHub. + + ## Node content conventions + + Three label formats are recognised inside node shapes: + + - `name
Label1 Label2` — our export format: name on top, space-joined + semantic labels below + - `LABEL · name` — yarn convention: a single label and name separated by ` · ` + - plain text — treated as the name with no labels + + `click id "text"` lines become the node `description` property. + + ## UUID identity + + Each node's UUID is derived deterministically from its **Mermaid node id** + (the `\w+` identifier, e.g. `val_0`, `std_ulogic`) via + `Artefact.UUID.from_name/1`. The display name inside the shape label is not + used. This means: + + - The same diagram imported twice produces the same artefact — safe to repeat. + - Two diagrams that share a node id will bind via `combine!/2` without any + manual UUID management. + - Renaming a node id changes its UUID and breaks bindings. Keep ids stable. + + ## Inline edge + node syntax + + When a node's shape is declared on the same line as an edge + (`A["label"] -->|TYPE| B["label"]`), only the **edge** is registered; the + node label is not captured. Use a separate declaration line to preserve + labels and names: + + graph LR + val_0["VALUE · 0"] + val_0 -->|ENUMERATES| value + + The round-trip format produced by `export/2` always emits separate node and + edge lines, so this limitation does not affect round-trips. + + ## Options + + * `:title` — overrides the title parsed from YAML front matter + * `:description` — overrides the description parsed from `accDescr:` + * `:base_label` — sets the artefact base label (not inferred from source) + + ## Example + + iex> source = \""" + ...> --- + ...> title: Us Two + ...> --- + ...> graph LR + ...> n0(("Matt
Agent Me")) + ...> n1(("Claude
Agent You")) + ...> n0 -->|US_TWO| n1 + ...> \""" + iex> artefact = Artefact.Mermaid.from_mmd!(source) + iex> artefact.title + "Us Two" + iex> length(artefact.graph.nodes) + 2 + + """ + def from_mmd!(source, opts \\ []) do + require Artefact + {parsed_title, parsed_desc, node_decls, edge_decls, click_decls} = parse_mmd(source) + + title = Keyword.get(opts, :title, parsed_title) + description = Keyword.get(opts, :description, parsed_desc) + base_label = Keyword.get(opts, :base_label) + + all_ids = + (Map.keys(node_decls) ++ + Enum.flat_map(edge_decls, fn {f, _t, to} -> [f, to] end)) + |> Enum.uniq() + + id_to_key = all_ids |> Enum.with_index() |> Map.new(fn {id, i} -> {id, :"n#{i}"} end) + + nodes = + Enum.map(all_ids, fn id -> + {name, labels} = Map.get(node_decls, id, {id, []}) + desc = Map.get(click_decls, id) + props = if desc, do: %{"name" => name, "description" => desc}, else: %{"name" => name} + {id_to_key[id], [labels: labels, properties: props, uuid: Artefact.UUID.from_name(id)]} + end) + + relationships = + edge_decls + |> Enum.map(fn {from_id, type, to_id} -> + [from: id_to_key[from_id], type: type, to: id_to_key[to_id]] + end) + |> Enum.uniq() + + Artefact.new!( + title: title, + description: description, + base_label: base_label, + nodes: nodes, + relationships: relationships + ) + end + + defp parse_mmd(source) do + {title_from_fm, body} = strip_front_matter(source) + + {title, desc, node_decls, edge_decls, click_decls} = + body + |> String.split("\n") + |> Enum.map(&String.trim/1) + |> collect_lines() + + {title_from_fm || title, desc, node_decls, edge_decls, click_decls} + end + + defp strip_front_matter(source) do + case Regex.run(~r/\A---\n(.*?)\n---\n/s, source) do + [matched, fm_body] -> + title = + case Regex.run(~r/^title:\s*["']?(.+?)["']?\s*$/m, fm_body) do + [_, t] -> String.trim(t, "\"") + nil -> nil + end + + {title, String.slice(source, String.length(matched)..-1//1)} + + nil -> + {nil, source} + end + end + + defp collect_lines(lines) do + acc = {nil, nil, %{}, [], %{}, false, []} + + {title, desc, node_decls, edge_decls, click_decls, _in_descr, _descr_lines} = + Enum.reduce(lines, acc, fn line, state -> + {title, desc, nodes, edges, clicks, in_descr, descr_lines} = state + + cond do + # Close accDescr block + in_descr and Regex.match?(~r/^\}/, line) -> + {title, Enum.join(Enum.reverse(descr_lines), "\n"), nodes, edges, clicks, false, []} + + # Accumulate accDescr block lines + in_descr -> + {title, desc, nodes, edges, clicks, true, [String.trim_leading(line, " ") | descr_lines]} + + # accDescr block open + Regex.match?(~r/^accDescr\s*\{/, line) -> + {title, desc, nodes, edges, clicks, true, []} + + # accDescr inline + m = Regex.run(~r/^accDescr:\s*(.+)$/, line) -> + [_, d] = m + {title, d, nodes, edges, clicks, false, []} + + # accTitle (ignore — title comes from front matter or opts) + Regex.match?(~r/^accTitle:/, line) -> + state + + # graph declaration, subgraph, end, comments, blank — skip + Regex.match?(~r/^(?:graph\s|subgraph\s|end$|%%|$)/, line) -> + state + + # click tooltip: click id "text" + m = Regex.run(~r/^click\s+(\w+)\s+"([^"]*)"/, line) -> + [_, id, tooltip] = m + {title, desc, nodes, edges, Map.put(clicks, id, unescape_html(tooltip)), false, []} + + # edge with label: id -->|TYPE| id (also handles inline node shapes like A["label"] -->|TYPE| B) + m = Regex.run(~r/^(\w+).*?(?:-->|-\.->|===>)\|([^|]+)\|\s*(\w+)/, line) -> + [_, from_id, type, to_id] = m + {title, desc, nodes, [{from_id, type, to_id} | edges], clicks, false, []} + + # node — try most specific format first + m = Regex.run(~r/^(\w+)\(\("(.+?)"\)\)/, line) -> + [_, id, content] = m + {title, desc, Map.put_new(nodes, id, parse_node_content(content)), edges, clicks, false, []} + + m = Regex.run(~r/^(\w+)\["(.+?)"\]/, line) -> + [_, id, content] = m + {title, desc, Map.put_new(nodes, id, parse_node_content(content)), edges, clicks, false, []} + + m = Regex.run(~r/^(\w+)\("(.+?)"\)/, line) -> + [_, id, content] = m + {title, desc, Map.put_new(nodes, id, parse_node_content(content)), edges, clicks, false, []} + + m = Regex.run(~r/^(\w+)\[([^\]]+)\]/, line) -> + [_, id, content] = m + {title, desc, Map.put_new(nodes, id, parse_node_content(content)), edges, clicks, false, []} + + m = Regex.run(~r/^(\w+)\(([^)]+)\)/, line) -> + [_, id, content] = m + {title, desc, Map.put_new(nodes, id, parse_node_content(content)), edges, clicks, false, []} + + true -> + state + end + end) + + {title, desc, node_decls, Enum.reverse(edge_decls), click_decls} + end + + defp parse_node_content(content) do + raw = unescape_html(content) + + cond do + String.contains?(raw, "
") -> + [name_part, labels_part] = String.split(raw, "
", parts: 2) + labels = labels_part |> String.split(" ") |> Enum.reject(&(&1 == "")) + {String.trim(name_part), labels} + + String.contains?(raw, " · ") -> + [label, name] = String.split(raw, " · ", parts: 2) + {String.trim(name), [String.trim(label)]} + + true -> + {String.trim(raw), []} + end + end + + defp unescape_html(s) do + s + |> String.replace(""", "\"") + |> String.replace("&", "&") + |> String.replace("|", "|") + |> String.replace("<", "<") + |> String.replace(">", ">") + end + # Mermaid node label text inside `(("..."))` — escape double quotes only; # `
` is rendered as a line break, which is what we want. defp escape(value) do diff --git a/artefact/lib/artefact/op.ex b/artefact/lib/artefact/op.ex index 31eac86..78ddf7a 100644 --- a/artefact/lib/artefact/op.ex +++ b/artefact/lib/artefact/op.ex @@ -157,11 +157,16 @@ defmodule Artefact.Op do nodes_from_a = Enum.map(a1.graph.nodes, fn n -> Map.get(primary_updates, n.uuid, n) end) + rel_offset = length(a1.graph.relationships) + rels_from_b = - Enum.map(a2.graph.relationships, fn rel -> + a2.graph.relationships + |> Enum.with_index(rel_offset) + |> Enum.map(fn {rel, i} -> %{ rel - | from_id: Map.get(b_id_remap, rel.from_id, rel.from_id), + | id: "r#{i}", + from_id: Map.get(b_id_remap, rel.from_id, rel.from_id), to_id: Map.get(b_id_remap, rel.to_id, rel.to_id) } end) diff --git a/artefact/lib/artefact/uuid.ex b/artefact/lib/artefact/uuid.ex index 878620b..43d1dcc 100644 --- a/artefact/lib/artefact/uuid.ex +++ b/artefact/lib/artefact/uuid.ex @@ -2,7 +2,14 @@ # SPDX-License-Identifier: MIT defmodule Artefact.UUID do - @moduledoc false + @moduledoc """ + UUIDv7 generation and validation for Artefact node identity. + + The key public function for consumers is `from_name/1` — derive a stable, + deterministic UUID from any string identifier. Use it when importing from + external sources (Mermaid, Cypher, JSON) to ensure the same node always + gets the same UUID across repeated imports. + """ import Bitwise # 8-4-4-4-12 hex with hyphens, version digit "7" at offset 14, variant in @@ -29,6 +36,23 @@ defmodule Artefact.UUID do format(a, b, 0x7000 ||| c, 0x8000000000000000 ||| d) end + @doc """ + Deterministic UUIDv7-shaped identifier derived from a name string. + + Uses SHA-256 of the name in place of random bytes, with version and variant + bits forced identically to `generate_v7/0`. The timestamp field is filled from + the hash rather than the clock, so the result is not time-ordered, but it is + stable: the same name always produces the same UUID. Passes `valid?/1`. + """ + def from_name(name) when is_binary(name) do + <> = :crypto.hash(:sha256, name) + + <> = + <> + + format(a, b, 0x7000 ||| c, 0x8000000000000000 ||| d) + end + @doc "Compare two UUIDv7 strings. Returns the lower (earlier) of the two." def harmonise(uuid_a, uuid_b) when uuid_a <= uuid_b, do: uuid_a def harmonise(_uuid_a, uuid_b), do: uuid_b diff --git a/artefact/lib/mix/tasks/artefact.install.ex b/artefact/lib/mix/tasks/artefact.install.ex new file mode 100644 index 0000000..066a8a8 --- /dev/null +++ b/artefact/lib/mix/tasks/artefact.install.ex @@ -0,0 +1,61 @@ +# SPDX-FileCopyrightText: 2026 artefactory contributors +# SPDX-License-Identifier: MIT + +defmodule Mix.Tasks.Artefact.Install.Docs do + @moduledoc false + + def short_doc, do: "Installs Artefact" + def example, do: "mix igniter.install artefact" + + def long_doc do + """ + #{short_doc()} + + ## Example + + ```bash + #{example()} + ``` + """ + end +end + +if Code.ensure_loaded?(Igniter) do + defmodule Mix.Tasks.Artefact.Install do + @shortdoc "#{__MODULE__.Docs.short_doc()}" + @moduledoc __MODULE__.Docs.long_doc() + + use Igniter.Mix.Task + + @impl Igniter.Mix.Task + def info(_argv, _composing_task) do + %Igniter.Mix.Task.Info{ + group: :artefact, + example: __MODULE__.Docs.example() + } + end + + @impl Igniter.Mix.Task + def igniter(igniter) do + igniter + |> Igniter.Project.Formatter.import_dep(:artefact) + end + end +else + defmodule Mix.Tasks.Artefact.Install do + @shortdoc "#{__MODULE__.Docs.short_doc()} | Install `igniter` to use" + @moduledoc __MODULE__.Docs.long_doc() + + use Mix.Task + + def run(_argv) do + Mix.shell().error(""" + The task 'artefact.install' requires igniter. Please install igniter and try again. + + For more information, see: https://hexdocs.pm/igniter/readme.html#installation + """) + + exit({:shutdown, 1}) + end + end +end diff --git a/artefact/mix.exs b/artefact/mix.exs index 121c326..8841418 100644 --- a/artefact/mix.exs +++ b/artefact/mix.exs @@ -5,7 +5,7 @@ defmodule Artefact.MixProject do @moduledoc false use Mix.Project - @version "0.2.0" + @version "0.3.0" @github_url "https://github.com/diffo-dev/artefactory" def project do @@ -35,6 +35,7 @@ defmodule Artefact.MixProject do [ {:jason, "~> 1.4"}, {:splode, "~> 0.3"}, + {:igniter, ">= 0.6.29 and < 1.0.0-0", optional: true}, {:ex_doc, "~> 0.37", only: [:dev, :test], runtime: false} ] end @@ -42,7 +43,7 @@ defmodule Artefact.MixProject do defp package do [ licenses: ["MIT"], - files: ~w(lib .formatter.exs mix.exs README* CHANGELOG* MIGRATION* LICENSES), + files: ~w(lib .formatter.exs mix.exs README* CHANGELOG* MIGRATION* LICENSES usage-rules.md), links: %{"GitHub" => @github_url} ] end diff --git a/artefact/mix.lock b/artefact/mix.lock index 550b7ce..11a6ee0 100644 --- a/artefact/mix.lock +++ b/artefact/mix.lock @@ -1,10 +1,26 @@ %{ "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, + "ex_ast": {:hex, :ex_ast, "0.11.2", "8a0330e7b2bc664b52d7b60b8d5faa2628cd6e02fe80f89e726ff20328411dbf", [:mix], [{:sourceror, "~> 1.7", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "df343260bd443306ca5da232615f1534a4aa740960055a4699e493fad8b4bd66"}, "ex_doc": {:hex, :ex_doc, "0.40.1", "67542e4b6dde74811cfd580e2c0149b78010fd13001fda7cfeb2b2c2ffb1344d", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "bcef0e2d360d93ac19f01a85d58f91752d930c0a30e2681145feea6bd3516e00"}, + "finch": {:hex, :finch, "0.22.0", "5c48fa6f9706a78eb9036cacb67b8b996b4e66d111c543f4c29bb0f879a6806b", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b94e83c47780fc6813f746a1f1a34ee65cda42da4c5ea26a68f0acc4498e23dc"}, + "glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"}, + "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, + "igniter": {:hex, :igniter, "0.8.0", "c7cab589440e5f20ff68e00f60eb094378114dab3105c0784ce8140f8dfdd2c0", [:mix], [{:ex_ast, "~> 0.5", [hex: :ex_ast, repo: "hexpm", optional: false]}, {:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "fcd99096fde4797f7b48bebddcfc58785569acd696346a3eb385bf813f47a7cc"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, "makeup_erlang": {:hex, :makeup_erlang, "1.0.3", "4252d5d4098da7415c390e847c814bad3764c94a814a0b4245176215615e1035", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "953297c02582a33411ac6208f2c6e55f0e870df7f80da724ed613f10e6706afd"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "mint": {:hex, :mint, "1.8.0", "b964eaf4416f2dee2ba88968d52239fca5621b0402b9c95f55a08eb9d74803e9", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "f3c572c11355eccf00f22275e9b42463bc17bd28db13be1e28f8e0bb4adbc849"}, + "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, + "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "owl": {:hex, :owl, "0.13.0", "26010e066d5992774268f3163506972ddac0a7e77bfe57fa42a250f24d6b876e", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "59bf9d11ce37a4db98f57cb68fbfd61593bf419ec4ed302852b6683d3d2f7475"}, + "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"}, + "rewrite": {:hex, :rewrite, "1.3.0", "67448ba7975690b35ba7e7f35717efcce317dbd5963cb0577aa7325c1923121a", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "d111ac7ff3a58a802ef4f193bbd1831e00a9c57b33276e5068e8390a212714a5"}, + "sourceror": {:hex, :sourceror, "1.12.0", "da354c5f35aad3cc1132f5d5b0d8437d865e2661c263260480bab51b5eedb437", [:mix], [], "hexpm", "755703683bd014ebcd5de9acc24b68fb874a660a568d1d63f8f98cd8a6ef9cd0"}, + "spitfire": {:hex, :spitfire, "0.3.11", "79dfcb033762470de472c1c26ea2b4e3aca74700c685dbffd9a13466272c323d", [:mix], [], "hexpm", "eb6e2dadf63214e8bfe65ca9788cef2b03b01027365d78d3c0e3d9ebd3d5b7b4"}, "splode": {:hex, :splode, "0.3.1", "9843c54f84f71b7833fec3f0be06c3cfb5be6b35960ee195ea4fad84b1c25030", [:mix], [], "hexpm", "8f2309b6ec2ecbb01435656429ed1d9ed04ba28797a3280c3b0d1217018ecfbd"}, + "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, + "text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"}, } diff --git a/artefact/test/artefact_test.exs b/artefact/test/artefact_test.exs index fb954dc..d3c887c 100644 --- a/artefact/test/artefact_test.exs +++ b/artefact/test/artefact_test.exs @@ -556,6 +556,7 @@ defmodule ArtefactTest do tag: :self_harmonise, details: %{uuid: uuid} }} = Artefact.harmonise(a, a, bindings) + assert uuid == a.uuid end @@ -1309,6 +1310,34 @@ defmodule ArtefactTest do Artefact.combine!(heart, other) end end + + test "combine!/2 produces unique relationship IDs" do + shared_uuid = "019d0000-0000-7000-8000-000000000099" + + a = + Artefact.new!( + base_label: "Knowing", + nodes: [ + shared: [labels: ["Node"], uuid: shared_uuid], + b: [labels: ["B"]] + ], + relationships: [[from: :shared, type: "KNOWS", to: :b]] + ) + + b = + Artefact.new!( + base_label: "Valuing", + nodes: [ + shared: [labels: ["Node"], uuid: shared_uuid], + c: [labels: ["C"]] + ], + relationships: [[from: :shared, type: "LOVES", to: :c]] + ) + + combined = Artefact.combine!(a, b) + ids = Enum.map(combined.graph.relationships, & &1.id) + assert ids == Enum.uniq(ids), "duplicate relationship IDs after combine!/2" + end end describe "Artefact.graft/3 — happy path with OurShells fixture" do @@ -1954,4 +1983,158 @@ defmodule ArtefactTest do assert Artefact.is_valid?(result) end end + + describe "Artefact.UUID.from_name/1" do + test "produces a valid UUIDv7" do + uuid = Artefact.UUID.from_name("n0") + assert Artefact.UUID.valid?(uuid) + end + + test "is deterministic — same name always gives same UUID" do + assert Artefact.UUID.from_name("hello") == Artefact.UUID.from_name("hello") + end + + test "different names give different UUIDs" do + refute Artefact.UUID.from_name("n0") == Artefact.UUID.from_name("n1") + end + end + + describe "Artefact.Mermaid.from_mmd!/2" do + test "parses a minimal exported diagram (round-trip node/rel counts)" do + require Artefact + + source = Artefact.new!( + title: "UsTwo", + base_label: "Agent", + nodes: [ + matt: [labels: ["Agent", "Me"], properties: %{"name" => "Matt"}], + claude: [labels: ["Agent", "You"], properties: %{"name" => "Claude"}] + ], + relationships: [[from: :matt, type: "US_TWO", to: :claude]] + ) + |> Artefact.Mermaid.export() + + result = Artefact.Mermaid.from_mmd!(source) + + assert length(result.graph.nodes) == 2 + assert length(result.graph.relationships) == 1 + end + + test "round-trip: export → from_mmd! → export produces identical output" do + require Artefact + + a = Artefact.new!( + title: "Round Trip", + base_label: "Concept", + nodes: [ + x: [labels: ["Concept"], properties: %{"name" => "Alpha"}], + y: [labels: ["Concept"], properties: %{"name" => "Beta"}] + ], + relationships: [[from: :x, type: "RELATES", to: :y]] + ) + + mmd1 = Artefact.Mermaid.export(a) + mmd2 = mmd1 |> Artefact.Mermaid.from_mmd!() |> Artefact.Mermaid.export() + + assert mmd1 == mmd2 + end + + test "click tooltip becomes node description property" do + source = """ + graph LR + n0(("Alice")) + click n0 "The description" + """ + + result = Artefact.Mermaid.from_mmd!(source) + node = hd(result.graph.nodes) + assert node.properties["description"] == "The description" + end + + test "parses hand-authored mermaid with bracket nodes and edge labels" do + source = """ + graph LR + A[Alpha] -->|KNOWS| B[Beta] + B -->|USES| C[Gamma] + """ + + result = Artefact.Mermaid.from_mmd!(source) + assert length(result.graph.nodes) == 3 + assert length(result.graph.relationships) == 2 + types = Enum.map(result.graph.relationships, & &1.type) + assert "KNOWS" in types + assert "USES" in types + end + + test "same mermaid node id in two diagrams produces same UUID" do + # node names default to their mermaid id when declared inline on an edge line + source_a = "graph LR\n shared -->|REL| other_a" + source_b = "graph LR\n shared -->|REL| other_b" + + result_a = Artefact.Mermaid.from_mmd!(source_a) + result_b = Artefact.Mermaid.from_mmd!(source_b) + + uuid_a = Enum.find(result_a.graph.nodes, &(&1.properties["name"] == "shared")).uuid + uuid_b = Enum.find(result_b.graph.nodes, &(&1.properties["name"] == "shared")).uuid + + assert uuid_a == uuid_b + end + + test "shared UUID enables combine!/2 to find binding across two parsed diagrams" do + require Artefact + + source_a = "graph LR\n shared(\"Hub\") -->|TO| leaf_a(\"Leaf A\")" + source_b = "graph LR\n shared(\"Hub\") -->|TO| leaf_b(\"Leaf B\")" + + a = Artefact.Mermaid.from_mmd!(source_a, base_label: "Hub") + b = Artefact.Mermaid.from_mmd!(source_b, base_label: "Leaf") + + combined = Artefact.combine!(a, b) + + # 3 unique nodes (shared hub + leaf_a + leaf_b) and 2 relationships + assert length(combined.graph.nodes) == 3 + assert length(combined.graph.relationships) == 2 + end + + test "YAML front matter title is parsed" do + source = """ + --- + title: My Diagram + --- + graph LR + a(("Alpha")) -->|REL| b(("Beta")) + """ + + result = Artefact.Mermaid.from_mmd!(source) + assert result.title == "My Diagram" + end + + test ":title opt overrides parsed front matter" do + source = """ + --- + title: Original + --- + graph LR + a(("Alpha")) + """ + + result = Artefact.Mermaid.from_mmd!(source, title: "Override") + assert result.title == "Override" + end + + test "LABEL · name convention recovers both label and name" do + # Separate declaration line so the node regex fires (not the edge regex) + source = """ + graph LR + val_0["VALUE · 0"] + value["VALUE · value"] + val_0 -->|ENUMERATES| value + """ + + result = Artefact.Mermaid.from_mmd!(source) + node = Enum.find(result.graph.nodes, &(&1.properties["name"] == "0")) + assert node != nil + assert "VALUE" in node.labels + end + end end diff --git a/artefact/usage-rules.md b/artefact/usage-rules.md new file mode 100644 index 0000000..f272315 --- /dev/null +++ b/artefact/usage-rules.md @@ -0,0 +1,160 @@ + + +# Rules for working with Artefact + +## Installation + +The preferred way to add Artefact to a project is via Igniter: + +```bash +mix igniter.install artefact +``` + +This wires up the formatter automatically. If your project does not use Igniter, add the dep manually and run `mix deps.get`. + +## What Artefact is + +Artefact is an Elixir library for building, combining, and persisting knowledge graph fragments. An `%Artefact{}` is a named, typed property graph — a small, self-contained piece of knowledge. It is not an application data model, a database schema, or a general-purpose graph library. + +The intended use is knowledge memorialisation: capturing shared understanding between people, agents, or systems in a form that can be combined, versioned, and persisted. + +## require Artefact + +`Artefact.new/1`, `Artefact.new!/1`, `Artefact.combine!/2`, and all other operations are **macros**. Always `require Artefact` before calling them: + +```elixir +require Artefact + +artefact = Artefact.new!( + title: "Us Two", + nodes: [ + matt: [labels: ["Agent"], properties: %{"name" => "Matt"}], + claude: [labels: ["Agent"], properties: %{"name" => "Claude"}] + ], + relationships: [ + [from: :matt, type: "US_TWO", to: :claude] + ] +) +``` + +Without `require`, you will get a compile-time error about undefined functions. This is the most common source of confusion when first using Artefact. + +## UUID is identity + +Every node carries a UUIDv7 `uuid`. This is its identity — the same UUID in two artefacts means the same node. `combine!/2` uses UUID equality to find shared nodes and merge them. + +**Never change a UUID once it has been used in a persisted or shared artefact.** If you assign a UUID explicitly at construction time, keep it. If you do not assign one, Artefact generates a time-ordered UUIDv7 automatically. + +For importing from external sources — Mermaid diagrams, Cypher files, JSON — derive UUIDs deterministically from a stable identifier using `Artefact.UUID.from_name/1`: + +```elixir +uuid = Artefact.UUID.from_name("std_ulogic") +# same name always → same UUID, valid UUIDv7 +``` + +This means the same external id imported twice always produces the same node, and `combine!/2` will bind correctly across imports. + +## Operations at a glance + +| Operation | What it does | Key constraint | +|---|---|---| +| `new!/1` | Build a fresh artefact | Macro — `require Artefact` | +| `combine!/2` | Union two artefacts via shared UUIDs | Different `base_label` required | +| `harmonise!/3` | Union via explicit bindings | Different `base_label` required | +| `compose!/2` | Concatenate — nodes stay disjoint | No shared UUIDs expected | +| `graft!/2` | Extend an existing artefact inline | Every new node must carry `:uuid` | + +Each operation has a `!/n` (raises) and `/n` (returns `{:ok, _} | {:error, _}`) variant. + +## combine!/2 requires different base_label values + +`combine!/2` finds shared nodes automatically by UUID. It requires the two artefacts to have **different** `base_label` values — this distinguishes "what I know" from "what I am adding": + +```elixir +# Raises Artefact.Error.Operation with same_base_label +Artefact.combine!(a, b) # both default base_label to calling module name + +# Correct +a = Artefact.new!(base_label: "Signals", ...) +b = Artefact.new!(base_label: "Values", ...) +combined = Artefact.combine!(a, b) +``` + +When no `base_label` is set explicitly, it defaults to the short name of the calling module — so two artefacts built in the same module will clash. + +## Mermaid import and export + +`Artefact.Mermaid.export/2` converts an artefact to a Mermaid `graph` source string. `Artefact.Mermaid.from_mmd!/2` parses it back. The round-trip is lossless for: title, description, node names, labels, and relationship types. + +### UUID identity anchors on the Mermaid node id + +When importing with `from_mmd!/2`, the UUID of each node is derived from its **Mermaid node id** — the `\w+` identifier (e.g. `val_0`, `std_ulogic`) — not the display label inside the shape. Keep node ids stable across diagram versions; changing an id changes the UUID and breaks bindings. + +```mermaid +graph LR + std_ulogic(("std_ulogic
Signal")) ← id is std_ulogic, UUID derived from "std_ulogic" +``` + +### Declare nodes separately from edges for label recovery + +When a node's shape is declared inline on an edge line, the label is not captured: + +``` +val_0["VALUE · 0"] -->|ENUMERATES| value ← label "VALUE" is lost +``` + +Use a separate declaration line: + +``` +graph LR + val_0["VALUE · 0"] + val_0 -->|ENUMERATES| value +``` + +The export format produced by `export/2` always uses separate lines, so this only applies to hand-authored Mermaid. + +### Node label conventions in Mermaid + +Two formats are recognised inside node shapes: + +- `name
Label1 Label2` — our export format +- `LABEL · name` — yarn convention (one label and name separated by ` · `) + +### Node descriptions via click tooltips + +Node `description` properties are exported as `click id "text"` lines and recovered on import. They are visible as hover tooltips in Mermaid renderers. + +## %Artefact{} struct shape + +```elixir +%Artefact{ + uuid: "019e...", # UUIDv7 — the artefact's own identity + title: "My Artefact", # optional + description: "...", # optional + base_label: "Concept", # optional — collapsed into per-node labels at export + graph: %Artefact.Graph{ + nodes: [ + %Artefact.Node{ + id: "n0", # internal sequential id — do not rely on this across artefacts + uuid: "019e...", # UUIDv7 — stable identity + labels: ["Concept", "Thing"], + properties: %{"name" => "Alpha", "description" => "..."} + } + ], + relationships: [ + %Artefact.Relationship{ + id: "r0", # internal sequential id + type: "RELATES", # MACRO_CASE convention + from_id: "n0", + to_id: "n1", + properties: %{} + } + ] + } +} +``` + +Node `id` values (`n0`, `r0`) are internal and sequential within one artefact. Use `uuid` for stable cross-artefact identity. diff --git a/artefact_kino/CHANGELOG.md b/artefact_kino/CHANGELOG.md index b70532d..c584925 100644 --- a/artefact_kino/CHANGELOG.md +++ b/artefact_kino/CHANGELOG.md @@ -5,6 +5,21 @@ SPDX-License-Identifier: MIT # Changelog +## 0.3.0 — 2026-05-13 + +### Side-by-side layout options + +- `description_lines:` option — reserves a fixed-height description area of exactly N lines. Content is clipped if longer; space is held empty if there is no description. Ensures two side-by-side panels share the same header height regardless of description length. Closes [#39](https://github.com/diffo-dev/artefactory/issues/39). +- `panel_height_px:` option — fixes the total widget height. The header takes what it needs; the graph row fills the rest. Prevents page reflow as artefacts grow across panels in a progressive-reveal livebook. + +### Bug fix + +- Node colours are now derived from a deterministic string hash of the label name rather than the label's position in the sorted list. The same label string always produces the same colour regardless of how many other labels exist in the artefact — a node can be followed by colour across panels without thinking about it. Closes [#39](https://github.com/diffo-dev/artefactory/issues/39). + +### Dependencies + +- Bumps `artefact` requirement to `~> 0.3.0`. + ## 0.2.0 — 2026-05-05 - Bumps `artefact` requirement to `~> 0.2.0`. `ArtefactKino.new/1,2` continues to validate its input via `Artefact.validate!/1`, which now raises `Artefact.Error.Invalid` instead of `ArgumentError` when an invalid artefact is passed in. Behaviour is otherwise unchanged. diff --git a/artefact_kino/README.md b/artefact_kino/README.md index 781bf50..1ce30da 100644 --- a/artefact_kino/README.md +++ b/artefact_kino/README.md @@ -29,7 +29,7 @@ MERGE Cypher upserts nodes by uuid — safe to run repeatedly. CREATE always mak ```elixir def deps do [ - {:artefact_kino, "~> 0.1"} + {:artefact_kino, "~> 0.3"} ] end ``` diff --git a/artefact_kino/artefact_kino.livemd b/artefact_kino/artefact_kino.livemd index 59f72cb..4b857df 100644 --- a/artefact_kino/artefact_kino.livemd +++ b/artefact_kino/artefact_kino.livemd @@ -7,7 +7,7 @@ SPDX-License-Identifier: MIT ```elixir Mix.install([ - {:artefact_kino, "~> 0.1.3"}, + {:artefact_kino, "~> 0.3.0"}, {:req, "~> 0.5"} ]) ``` @@ -96,6 +96,76 @@ Harmonise merges labels and properties of bound nodes, and properties of bound r The knowledge we have harmonized here is ArtefactCreateMerge, a combination of Artefact and the CreateMerge Artefacts. +## Side by Side + +A common pattern is showing knowledge grow progressively — a section artefact on the left, +the integrated whole on the right, chapter by chapter. `Kino.Layout.grid` places two +`ArtefactKino` widgets side by side. Two small things make this work well. + +**Consistent colours.** The same label string always produces the same colour, regardless +of how many labels either artefact contains. An `Agent` node will be the same shade of +blue-green in a two-label artefact as it is in a ten-label one. You can follow a concept +by colour across panels without thinking about it. + +**Stable header height.** When artefacts have descriptions of different lengths — or when +one has no description at all — the headers expand differently and the graph viewports no +longer align. Pass `description_lines:` to reserve a fixed-height description area of +exactly that many lines. Content is clipped if it is longer; the space is held empty if +there is no description. Both panels share the same header height and the graphs line up. + +```elixir +require Artefact + +section = + Artefact.new!( + title: "Section 1", + base_label: "Knowing", + description: "MATCH (a:Agent)-[:KNOWS]->(b:Concept)\nRETURN a, b", + nodes: [ + matt: [labels: ["Agent"], properties: %{"name" => "Matt"}, uuid: "019da897-f2de-77ca-b5a4-40f0c3730943"], + knowing: [labels: ["Concept"], properties: %{"name" => "Knowing"}, uuid: "019da897-0000-7000-8000-000000000001"] + ], + relationships: [ + [from: :matt, type: "KNOWS", to: :knowing] + ] + ) + +integrated = + Artefact.new!( + title: "Integrated", + base_label: "Valuing", + description: "MATCH (a:Agent)-[:KNOWS|VALUES]->(b:Concept)\nRETURN a, b", + nodes: [ + matt: [labels: ["Agent"], properties: %{"name" => "Matt"}, uuid: "019da897-f2de-77ca-b5a4-40f0c3730943"], + know: [labels: ["Concept"], properties: %{"name" => "Knowing"}, uuid: "019da897-0000-7000-8000-000000000001"], + value: [labels: ["Concept"], properties: %{"name" => "Valuing"}, uuid: "019da897-0000-7000-8000-000000000002"] + ], + relationships: [ + [from: :matt, type: "KNOWS", to: :know], + [from: :matt, type: "VALUES", to: :value] + ] + ) + +Kino.Layout.grid([ + ArtefactKino.new(section, description_lines: 2), + ArtefactKino.new(integrated, description_lines: 2) +], columns: 2) +``` + +Add `panel_height_px:` to fix the total widget height. The header takes what it needs and +the graph row fills the rest. Use both options together and the widget dimensions are fully +locked — the page never reflows as the artefact grows across panels. + +```elixir +Kino.Layout.grid([ + ArtefactKino.new(section, description_lines: 2, panel_height_px: 600), + ArtefactKino.new(integrated, description_lines: 2, panel_height_px: 600) +], columns: 2) +``` + +Notice that the `Agent` node (Matt) is the same colour in both panels, and the two graph +viewports sit at the same height despite the descriptions being different lengths. + ## What Next? Artefacts are knowledge graph fragments. We like to store knowledge in graphical databases, so explore exporting cypher to a graph database with creat and merge. Review the CreateMerge Artifact to understand what create and merge are best for. diff --git a/artefact_kino/lib/artefact_kino.ex b/artefact_kino/lib/artefact_kino.ex index c4bb2e6..d5eb75d 100644 --- a/artefact_kino/lib/artefact_kino.ex +++ b/artefact_kino/lib/artefact_kino.ex @@ -28,14 +28,24 @@ defmodule ArtefactKino do Options: - `default:` — `:create` (default) or `:merge` + - `description_lines:` — integer; reserves a fixed-height description area + of exactly this many lines. Content is clipped if longer; an empty area + is reserved if there is no description. Keeps side-by-side graph viewports + at the same height regardless of description length or presence. + - `panel_height_px:` — integer; fixes the total widget height in pixels. + The header takes what it needs and the graph row fills the rest. Use + together with `description_lines:` to fully lock widget dimensions across + a side-by-side progression so the page never reflows between panels. """ def new(%Artefact{} = artefact, opts \\ []) do Artefact.validate!(artefact) default = Keyword.get(opts, :default, :create) - Kino.JS.new(__MODULE__, build_data(artefact, default)) + description_lines = Keyword.get(opts, :description_lines, nil) + panel_height_px = Keyword.get(opts, :panel_height_px, nil) + Kino.JS.new(__MODULE__, build_data(artefact, default, description_lines, panel_height_px)) end - defp build_data(artefact, default) do + defp build_data(artefact, default, description_lines, panel_height_px) do %{ nodes: vis_nodes(artefact), edges: vis_edges(artefact), @@ -46,6 +56,8 @@ defmodule ArtefactKino do default: Atom.to_string(default), title: artefact.title || artefact.base_label || "Artefact", description: artefact.description, + description_lines: description_lines, + panel_height_px: panel_height_px, artefact_rows: artefact_rows(artefact), nodes_rows: nodes_rows(artefact), rels_rows: rels_rows(artefact) @@ -135,13 +147,12 @@ defmodule ArtefactKino do // -- colour theory -- - function buildLabelHues(nodes) { - const labels = new Set(); - nodes.forEach(n => n.labels.forEach(l => labels.add(l))); - const sorted = [...labels].sort(); - const hues = {}; - sorted.forEach((l, i) => { hues[l] = (i / sorted.length) * 360; }); - return hues; + function labelToHue(label) { + let h = 0; + for (let i = 0; i < label.length; i++) { + h = (h * 31 + label.charCodeAt(i)) & 0xffffffff; + } + return (h >>> 0) % 360; } function blendHues(hues) { @@ -164,10 +175,9 @@ defmodule ArtefactKino do return "#" + [r, g, b].map(c => Math.round(Math.max(0, Math.min(1, c)) * 255).toString(16).padStart(2, "0")).join(""); } - function nodeColour(labels, labelHues) { + function nodeColour(labels) { if (!labels || labels.length === 0) return { bg: "#2a2a2a", border: "#555" }; - const hues = labels.map(l => labelHues[l] ?? 0); - const blended = blendHues(hues); + const blended = blendHues(labels.map(labelToHue)); const [r1, g1, b1] = hslToRGB(blended, 55, 30); const [r2, g2, b2] = hslToRGB(blended, 65, 50); return { @@ -198,7 +208,8 @@ defmodule ArtefactKino do } export function init(ctx, data) { - ctx.root.style.cssText = "font-family:monospace;background:#111;color:#e0e0e0;"; + ctx.root.style.cssText = "font-family:monospace;background:#111;color:#e0e0e0;" + + (data.panel_height_px ? `height:${data.panel_height_px}px;overflow:hidden;display:flex;flex-direction:column;` : ""); const escapeHtml = (s) => String(s) .replace(/&/g, "&") @@ -207,17 +218,30 @@ defmodule ArtefactKino do .replace(/"/g, """) .replace(/'/g, "'"); + const descHtml = (() => { + if (data.description_lines) { + const style = `font-size:11px;color:#888;margin-top:2px;font-style:italic;line-height:1.4;height:calc(${data.description_lines} * 1.4em);overflow:hidden;white-space:pre-line;`; + return `
${data.description ? escapeHtml(data.description) : ""}
`; + } + if (data.description) { + return `
${escapeHtml(data.description)}
`; + } + return ""; + })(); + const headerHtml = `
${escapeHtml(data.title)}
- ${data.description - ? `
${escapeHtml(data.description)}
` - : ""} + ${descHtml}
`; + const rowStyle = data.panel_height_px + ? "display:flex;flex:1;min-height:0;gap:0;" + : "display:flex;height:560px;gap:0;"; + ctx.root.innerHTML = ` ${headerHtml} -
+
@@ -373,10 +397,8 @@ defmodule ArtefactKino do }) .then(() => { if (!window.vis) return; - const labelHues = buildLabelHues(data.nodes); - const nodes = new vis.DataSet(data.nodes.map(n => { - const { bg, border } = nodeColour(n.labels, labelHues); + const { bg, border } = nodeColour(n.labels); return { ...n, shape: "ellipse", diff --git a/artefact_kino/mix.exs b/artefact_kino/mix.exs index fa2d876..af21264 100644 --- a/artefact_kino/mix.exs +++ b/artefact_kino/mix.exs @@ -5,7 +5,7 @@ defmodule ArtefactKino.MixProject do @moduledoc false use Mix.Project - @version "0.2.0" + @version "0.3.0" @github_url "https://github.com/diffo-dev/artefactory" def project do @@ -42,13 +42,13 @@ defmodule ArtefactKino.MixProject do defp artefact_dep do cond do System.get_env("HEX_PUBLISH") == "1" -> - {:artefact, "~> 0.2.0"} + {:artefact, "~> 0.3.0"} File.exists?(Path.join(__DIR__, "../artefact/mix.exs")) -> {:artefact, path: "../artefact"} true -> - {:artefact, "~> 0.2.0"} + {:artefact, "~> 0.3.0"} end end diff --git a/artefact_kino/mix.lock b/artefact_kino/mix.lock index 34d8e38..f8392ae 100644 --- a/artefact_kino/mix.lock +++ b/artefact_kino/mix.lock @@ -7,5 +7,6 @@ "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, "makeup_erlang": {:hex, :makeup_erlang, "1.0.3", "4252d5d4098da7415c390e847c814bad3764c94a814a0b4245176215615e1035", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "953297c02582a33411ac6208f2c6e55f0e870df7f80da724ed613f10e6706afd"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, + "splode": {:hex, :splode, "0.3.1", "9843c54f84f71b7833fec3f0be06c3cfb5be6b35960ee195ea4fad84b1c25030", [:mix], [], "hexpm", "8f2309b6ec2ecbb01435656429ed1d9ed04ba28797a3280c3b0d1217018ecfbd"}, "table": {:hex, :table, "0.1.2", "87ad1125f5b70c5dea0307aa633194083eb5182ec537efc94e96af08937e14a8", [:mix], [], "hexpm", "7e99bc7efef806315c7e65640724bf165c3061cdc5d854060f74468367065029"}, } diff --git a/artefactory_neo4j/CHANGELOG.md b/artefactory_neo4j/CHANGELOG.md index b1e6958..1d08a1b 100644 --- a/artefactory_neo4j/CHANGELOG.md +++ b/artefactory_neo4j/CHANGELOG.md @@ -1,5 +1,22 @@ + + # Changelog +## 0.3.0 — 2026-05-13 + +### Tooling + +- Igniter task `mix artefactory_neo4j.install` — preferred installation method; configures Bolty in `runtime.exs`, adds `Bolty` to the supervision tree, and installs the `artefact` dependency automatically. +- `usage-rules.md` — consumer-facing AI agent guidance, compatible with the `usage_rules` hex package ecosystem. +- `usage_rules` dev dependency added. + +### Dependencies + +- Bumps `artefact` requirement to `~> 0.3.0`. + ## 0.1.0 — 2026-04-21 Initial release. diff --git a/artefactory_neo4j/README.md b/artefactory_neo4j/README.md index ffb71ab..8b0c32c 100644 --- a/artefactory_neo4j/README.md +++ b/artefactory_neo4j/README.md @@ -17,10 +17,20 @@ Named databases on Neo4j Community Edition require **DozerDB** — a free plugin ## Installation +The preferred way to install ArtefactoryNeo4j is via Igniter: + +```bash +mix igniter.install artefactory_neo4j +``` + +This automatically configures Bolty in `runtime.exs`, adds `Bolty` to the supervision tree, and installs the `artefact` dependency. + +Or add the dependency manually: + ```elixir def deps do [ - {:artefactory_neo4j, "~> 0.1"} + {:artefactory_neo4j, "~> 0.3"} ] end ``` diff --git a/artefactory_neo4j/lib/mix/tasks/artefactory_neo4j.install.ex b/artefactory_neo4j/lib/mix/tasks/artefactory_neo4j.install.ex new file mode 100644 index 0000000..a9ee268 --- /dev/null +++ b/artefactory_neo4j/lib/mix/tasks/artefactory_neo4j.install.ex @@ -0,0 +1,90 @@ +# SPDX-FileCopyrightText: 2026 artefactory contributors +# SPDX-License-Identifier: MIT + +defmodule Mix.Tasks.ArtefactoryNeo4j.Install.Docs do + @moduledoc false + + def short_doc, do: "Installs ArtefactoryNeo4j" + def example, do: "mix igniter.install artefactory_neo4j" + + def long_doc do + """ + #{short_doc()} + + ## Example + + ```bash + #{example()} + ``` + """ + end +end + +if Code.ensure_loaded?(Igniter) do + defmodule Mix.Tasks.ArtefactoryNeo4j.Install do + @shortdoc "#{__MODULE__.Docs.short_doc()}" + @moduledoc __MODULE__.Docs.long_doc() + + use Igniter.Mix.Task + + @impl Igniter.Mix.Task + def info(_argv, _composing_task) do + %Igniter.Mix.Task.Info{ + group: :artefactory_neo4j, + installs: [{:artefact, "~> 0.2"}], + example: __MODULE__.Docs.example() + } + end + + @impl Igniter.Mix.Task + def igniter(igniter) do + igniter + |> Igniter.Project.Formatter.import_dep(:artefactory_neo4j) + |> Igniter.Project.Config.configure( + "runtime.exs", + :bolty, + [Bolt, :uri], + "bolt://localhost:7687" + ) + |> Igniter.Project.Config.configure( + "runtime.exs", + :bolty, + [Bolt, :auth], + username: "neo4j", + password: "password" + ) + |> Igniter.Project.Config.configure( + "runtime.exs", + :bolty, + [Bolt, :pool_size], + 10 + ) + |> Igniter.Project.Config.configure( + "runtime.exs", + :bolty, + [Bolt, :name], + Bolt + ) + |> Igniter.Project.Application.add_new_child( + {Bolty, {:code, quote(do: Application.get_env(:bolty, Bolt))}} + ) + end + end +else + defmodule Mix.Tasks.ArtefactoryNeo4j.Install do + @shortdoc "#{__MODULE__.Docs.short_doc()} | Install `igniter` to use" + @moduledoc __MODULE__.Docs.long_doc() + + use Mix.Task + + def run(_argv) do + Mix.shell().error(""" + The task 'artefactory_neo4j.install' requires igniter. Please install igniter and try again. + + For more information, see: https://hexdocs.pm/igniter/readme.html#installation + """) + + exit({:shutdown, 1}) + end + end +end diff --git a/artefactory_neo4j/mix.exs b/artefactory_neo4j/mix.exs index c77467e..4e8613c 100644 --- a/artefactory_neo4j/mix.exs +++ b/artefactory_neo4j/mix.exs @@ -5,7 +5,7 @@ defmodule ArtefactoryNeo4j.MixProject do @moduledoc false use Mix.Project - @version "0.1.0" + @version "0.3.0" @github_url "https://github.com/diffo-dev/artefactory" def project do @@ -30,8 +30,10 @@ defmodule ArtefactoryNeo4j.MixProject do defp deps do [ - {:artefact, "~> 0.1"}, + {:artefact, "~> 0.3.0"}, {:bolty, "~> 0.0.9"}, + {:igniter, ">= 0.6.29 and < 1.0.0-0", optional: true}, + {:usage_rules, "~> 1.0", only: :dev, runtime: false}, {:ex_doc, "~> 0.37", only: [:dev, :test], runtime: false} ] end @@ -39,7 +41,7 @@ defmodule ArtefactoryNeo4j.MixProject do defp package do [ licenses: ["MIT"], - files: ~w(lib .formatter.exs mix.exs README* CHANGELOG* LICENSES), + files: ~w(lib .formatter.exs mix.exs README* CHANGELOG* LICENSES usage-rules.md), links: %{"GitHub" => @github_url} ] end diff --git a/artefactory_neo4j/mix.lock b/artefactory_neo4j/mix.lock index 6a21235..7236db8 100644 --- a/artefactory_neo4j/mix.lock +++ b/artefactory_neo4j/mix.lock @@ -3,11 +3,27 @@ "bolty": {:hex, :bolty, "0.0.9", "c8026ce9804347f71e23b3a0cbc01b918ef94b61e159b5ba7fb48527878033ad", [:mix], [{:db_connection, "~> 2.7.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:poison, "~> 6.0", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm", "fc20c42550c0fce370276b4ef119e92792761b2fea1aef9cccf8de946bc39d35"}, "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, + "ex_ast": {:hex, :ex_ast, "0.11.2", "8a0330e7b2bc664b52d7b60b8d5faa2628cd6e02fe80f89e726ff20328411dbf", [:mix], [{:sourceror, "~> 1.7", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "df343260bd443306ca5da232615f1534a4aa740960055a4699e493fad8b4bd66"}, "ex_doc": {:hex, :ex_doc, "0.40.1", "67542e4b6dde74811cfd580e2c0149b78010fd13001fda7cfeb2b2c2ffb1344d", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "bcef0e2d360d93ac19f01a85d58f91752d930c0a30e2681145feea6bd3516e00"}, + "finch": {:hex, :finch, "0.22.0", "5c48fa6f9706a78eb9036cacb67b8b996b4e66d111c543f4c29bb0f879a6806b", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b94e83c47780fc6813f746a1f1a34ee65cda42da4c5ea26a68f0acc4498e23dc"}, + "glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"}, + "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, + "igniter": {:hex, :igniter, "0.8.0", "c7cab589440e5f20ff68e00f60eb094378114dab3105c0784ce8140f8dfdd2c0", [:mix], [{:ex_ast, "~> 0.5", [hex: :ex_ast, repo: "hexpm", optional: false]}, {:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "fcd99096fde4797f7b48bebddcfc58785569acd696346a3eb385bf813f47a7cc"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, "makeup_erlang": {:hex, :makeup_erlang, "1.0.3", "4252d5d4098da7415c390e847c814bad3764c94a814a0b4245176215615e1035", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "953297c02582a33411ac6208f2c6e55f0e870df7f80da724ed613f10e6706afd"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "mint": {:hex, :mint, "1.8.0", "b964eaf4416f2dee2ba88968d52239fca5621b0402b9c95f55a08eb9d74803e9", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "f3c572c11355eccf00f22275e9b42463bc17bd28db13be1e28f8e0bb4adbc849"}, + "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, + "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "owl": {:hex, :owl, "0.13.0", "26010e066d5992774268f3163506972ddac0a7e77bfe57fa42a250f24d6b876e", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "59bf9d11ce37a4db98f57cb68fbfd61593bf419ec4ed302852b6683d3d2f7475"}, + "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"}, + "rewrite": {:hex, :rewrite, "1.3.0", "67448ba7975690b35ba7e7f35717efcce317dbd5963cb0577aa7325c1923121a", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "d111ac7ff3a58a802ef4f193bbd1831e00a9c57b33276e5068e8390a212714a5"}, + "sourceror": {:hex, :sourceror, "1.12.0", "da354c5f35aad3cc1132f5d5b0d8437d865e2661c263260480bab51b5eedb437", [:mix], [], "hexpm", "755703683bd014ebcd5de9acc24b68fb874a660a568d1d63f8f98cd8a6ef9cd0"}, + "spitfire": {:hex, :spitfire, "0.3.11", "79dfcb033762470de472c1c26ea2b4e3aca74700c685dbffd9a13466272c323d", [:mix], [], "hexpm", "eb6e2dadf63214e8bfe65ca9788cef2b03b01027365d78d3c0e3d9ebd3d5b7b4"}, "telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"}, + "text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"}, + "usage_rules": {:hex, :usage_rules, "1.2.6", "a7b3f8d6e5d265701139d5714749c37c54bb82230a4c51ec54a12a1e4769b9d1", [:mix], [{:igniter, ">= 0.6.6 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "608411b9876a16a9d62a427dbaf42faf458e4cd0a508b3bd7e5ee71502073582"}, } diff --git a/artefactory_neo4j/usage-rules.md b/artefactory_neo4j/usage-rules.md new file mode 100644 index 0000000..7b289b1 --- /dev/null +++ b/artefactory_neo4j/usage-rules.md @@ -0,0 +1,109 @@ + + +# Rules for working with ArtefactoryNeo4j + +## Installation + +The preferred way to add ArtefactoryNeo4j to a project is via Igniter: + +```bash +mix igniter.install artefactory_neo4j +``` + +This automatically: configures Bolty connection details in `runtime.exs`, adds `Bolty` to the supervision tree, and installs the `artefact` dependency. If your project does not use Igniter, do these steps manually and run `mix deps.get`. + +## What ArtefactoryNeo4j is + +ArtefactoryNeo4j persists `%Artefact{}` structs to a Neo4j-compatible graph database via the Bolt protocol (Bolty driver). It is a persistence boundary for knowledge graph fragments — not an ORM, not a data layer, not a query builder. You bring the artefact; it handles the Cypher. + +Multi-database support (one named database per entity — Me, Mob, a You) requires DozerDB or Neo4j Enterprise. It is not available on Neo4j Community Edition. + +## Connection model + +ArtefactoryNeo4j uses **direct Bolty connections**, not a named supervised pool. There is no global `Bolt` process, no `Repo` module, no application-level config key to set. + +```elixir +{:ok, conn} = ArtefactoryNeo4j.connect( + uri: "bolt://localhost:7688", + auth: [username: "neo4j", password: "password"] +) +``` + +This is different from `ash_neo4j`, which starts a named `Bolt` process in the supervision tree and reads from `config :bolty, Bolt, ...`. Do not carry that pattern here. + +## The `db:` option is required on every query + +Every `write/3` and `fetch/3` call requires a `db:` option that names the target database. There is no default database. + +```elixir +:ok = ArtefactoryNeo4j.write(conn, artefact, db: "matt_artefactory") +{:ok, rows} = ArtefactoryNeo4j.fetch(conn, uuid, db: "matt_artefactory") +``` + +Omitting `db:` raises `KeyError` — it is a required key, not optional. + +## Database naming convention + +Database names follow Elixir convention in code — `snake_case` atom or string. ArtefactoryNeo4j converts them to Neo4j `kebab-case` automatically: + +```elixir +# These are equivalent +ArtefactoryNeo4j.write(conn, artefact, db: :matt_artefactory) +ArtefactoryNeo4j.write(conn, artefact, db: "matt_artefactory") +# Both write to Neo4j database "matt-artefactory" +``` + +Do not pass a `kebab-case` string directly — it will be double-converted. + +## Property naming convention + +Node property keys are converted at the Bolt boundary automatically: + +- Elixir `snake_case` → Neo4j `camelCase` on write +- Neo4j `camelCase` → Elixir `snake_case` on read + +```elixir +# In Elixir: %{"first_name" => "Matt"} +# In Neo4j: {firstName: "Matt"} +``` + +Do not manually convert property keys before passing them to ArtefactoryNeo4j, and do not expect `camelCase` keys in results. + +## write/3 uses MERGE — it is idempotent + +`write/3` generates parameterised `MERGE` Cypher, not `CREATE`. Calling it twice with the same artefact will update in place rather than create duplicate nodes. This is intentional — artefacts are knowledge fragments, and re-writing the same knowledge should be safe. + +## Connection pooling (DBConnection) + +Bolty is built on [DBConnection](https://hexdocs.pm/db_connection), which provides connection pooling. The `connect/1` call starts a pool — `pool_size:` controls how many concurrent Bolt connections it maintains. Tune this based on load in production. + +```elixir +{:ok, conn} = ArtefactoryNeo4j.connect( + uri: "bolt://localhost:7688", + auth: [username: "neo4j", password: "password"], + pool_size: 10 +) +``` + +ArtefactoryNeo4j does not currently expose transactions. If you need transactional writes, use `Bolty.transaction/4` directly on the connection returned by `connect/1` — see the Bolty documentation. + +## Database lifecycle (DozerDB) + +Named databases are a DozerDB / Neo4j Enterprise feature. Each entity in the diffo model has its own named database: + +```elixir +:ok = ArtefactoryNeo4j.create_database(conn, "matt_artefactory") +:ok = ArtefactoryNeo4j.write(conn, artefact, db: "matt_artefactory") + +# Lifecycle +:ok = ArtefactoryNeo4j.stop_database(conn, "matt_artefactory") +:ok = ArtefactoryNeo4j.start_database(conn, "matt_artefactory") +:ok = ArtefactoryNeo4j.drop_database(conn, "matt_artefactory") +``` + +All lifecycle operations route through the `system` database internally — you do not need to manage that yourself. + +`create_database/2` uses `IF NOT EXISTS` — safe to call repeatedly.