diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b90379b --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +# Copy to .env and set your values +NEO4J_PASSWORD=password diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd18177..86c8b70 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2026 diffo-dev +# SPDX-FileCopyrightText: 2026 artefactory contributors # SPDX-License-Identifier: MIT name: CI diff --git a/.gitignore b/.gitignore index 9ebef6d..2a4c8e6 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,7 @@ artefactory-*.tar /.elixir_ls -.DS_Store \ No newline at end of file +.DS_Store + +# Local dev secrets +.env \ No newline at end of file diff --git a/.gitignore.license b/.gitignore.license index bec7d25..d41bbec 100644 --- a/.gitignore.license +++ b/.gitignore.license @@ -1,3 +1,3 @@ -SPDX-FileCopyrightText: 2026 artefactory contributors +SPDX-FileCopyrightText: 2026 artefactory contributors SPDX-License-Identifier: MIT \ No newline at end of file diff --git a/.reuseignore b/.reuseignore new file mode 100644 index 0000000..b968fed --- /dev/null +++ b/.reuseignore @@ -0,0 +1,2 @@ +artefactory_neo4j/_build/** +artefactory_neo4j/deps/** diff --git a/.tool-versions.license b/.tool-versions.license index f59d620..51aeba7 100644 --- a/.tool-versions.license +++ b/.tool-versions.license @@ -1,2 +1,2 @@ -SPDX-FileCopyrightText: 2026 diffo-dev +SPDX-FileCopyrightText: 2026 2026 artefactory contributors SPDX-License-Identifier: MIT diff --git a/.tool_versions.license b/.tool_versions.license index bec7d25..d41bbec 100644 --- a/.tool_versions.license +++ b/.tool_versions.license @@ -1,3 +1,3 @@ -SPDX-FileCopyrightText: 2026 artefactory contributors +SPDX-FileCopyrightText: 2026 artefactory contributors SPDX-License-Identifier: MIT \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 652d19b..01252d1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,5 @@ @@ -71,15 +71,11 @@ Our creole words so far: - **us-two** — a word with no translation; the specific irreducible relationship between two participants -### us_two (the library — not yet built) +### us_two (the library — in `2026 artefactory contributors /us_two`) `us_two` is an independent Elixir/Spark DSL protocol library. -It will depend on `artefact`. Diffo will depend on `us_two`. - -The protocol is not Diffo-specific. Any two agents — any system, -any relationship — may declare it. - -**Do not build `us_two` yet.** It comes after `artefact` is solid. +It lives in its own repo (`us_two`) and depends on `artefact` and +`artefactory_neo4j` (optional, for persistence). It is not in this repo. --- @@ -110,15 +106,17 @@ The lexicon is expressed as an artefact: `artefact/test/data/artefactory/arrows. ## Artefactory — what we are building now -`artefactory` is a monorepo at `diffo-dev/artefactory` containing: +`artefactory` is a monorepo at `2026 artefactory contributors /artefactory` containing: ``` -artefactory/ ← repo root (the universe that holds them) - artefact/ → hex.pm/packages/artefact - artefact_kino/ → hex.pm/packages/artefact_kino +artefactory/ ← repo root (the universe that holds them) + artefact/ → hex.pm/packages/artefact + artefact_kino/ → hex.pm/packages/artefact_kino + artefactory_neo4j/ → hex.pm/packages/artefactory_neo4j + docker-compose.yml ← local DozerDB dev instance ``` -### Repo structure — two independent Mix projects, no umbrella +### Repo structure — independent Mix projects, no umbrella Each package has its own `mix.exs`, `deps/`, `_build/`, and tests. They are not an Elixir umbrella app. There is no root-level `mix.exs`. @@ -126,13 +124,14 @@ They are not an Elixir umbrella app. There is no root-level `mix.exs`. Work in each package independently: ```sh -cd artefact && mix test -cd artefact_kino && mix test +cd artefact && mix test +cd artefact_kino && mix test +cd artefactory_neo4j && mix test ``` -`artefact_kino` references `artefact` via a local path dep during -development (`path: "../artefact"`). When published to hex.pm they -become normal version deps. +`artefact_kino` and `artefactory_neo4j` reference `artefact` via a local +path dep during development (`path: "../artefact"`). When published to +hex.pm they become normal version deps. **`artefact_kino` is currently a placeholder.** The `mix.exs`, `lib/artefact_kino.ex` (stub with moduledoc), and `test/test_helper.exs` @@ -156,6 +155,42 @@ Artefacts are fragments, not complete models. One concept at a time. You see country from the clouds first — one landmark — then descend when you need detail. +### Artefacts as pictures and artefacts as mechanical toys + +Currently an artefact is a **picture** — a point-in-time knowledge fragment, +passed around, harmonised, and persisted. It has no behaviour of its own. + +An artefact could also be a **mechanical toy** — it has behaviour built in, +but not agency. The distinction is important: + +- A mechanical toy **acts** but does not **think**. It is stimulus-response, + like a module function. `artefact.ask(:who_are_you)` consults the knowledge + baked in at the moment the artefact was made and returns an answer. +- The knowledge is **point-in-time**. The response is deterministic given + that knowledge. Nothing about the artefact changes as a result of being asked. +- The artefact does **not adapt**. Adaptation belongs to the agent holding it. + The artefact is the product of adaptation, not a participant in it. + +The technical shape: a struct with named functions that pattern-match on +their own frozen data. Pure function over frozen state. No process, no +mailbox, no side effects. The artefact is the closure — knowledge captured +at a moment, behaviour defined by whoever made it. + +The maker's intent is encoded as mechanism. The artefact can have a +`persist/1` function but cannot decide *when* to persist. It can render +itself but cannot decide *who* to show itself to. The deciding stays with +the agent. + +**A possible future direction**: `artefactory` could provide a rich Spark DSL +for building artefacts that carry methods — functions that demonstrate, +explain, or query their own encapsulated knowledge. A compiled artefact +module (not just a runtime `%Artefact{}` struct) would have Spark transformers +inject those methods at compile time, making the artefact introspectable and +self-demonstrating. This is distinct from `%Artefact{}` as it exists today — +it would be a new kind of thing, built in artefactory country. + +Do not build this yet. Hold it as the direction `artefactory` could grow toward. + ### artefact — the core library **No Kino dependency. No Livebook dependency. No us_two concepts.** @@ -273,15 +308,92 @@ Style reference on `%Artefact{}` drives future render styles: - `:arrows_default` — faithful to arrows.app colours - `nil` — default (arrows_default) +### artefactory_neo4j — Neo4j persistence implementation + +Depends on `artefact` + `bolty`. No Kino, no Livebook, no us_two concepts. + +This is the **artefactory implementation for Neo4j** — an adapter that +persists `%Artefact{}` structs into a Neo4j graph database. It uses: + +- **Bolty** — Bolt-protocol driver for Neo4j (hex.pm/packages/bolty, supports Bolt 5.4) +- **DozerDB** — open-source plugin for Neo4j Community 5.26.3 that unlocks + enterprise multi-database features (CREATE/DROP/STOP/START DATABASE) + +#### Naming convention + +`artefactory_*` packages are **artefactory implementations** — adapters +that connect the artefact universe to a specific persistence or rendering +backend. The same pattern will be used for future backends (e.g. +`artefactory_ecto`). The backend name comes second, in artefactory country. + +#### Key design: one database per entity + +Each entity (Me, a Mob, a native You) gets its own **named Neo4j database**. +All entities share a single Bolt connection, with the `db:` option routing +each query to the correct database: + +```elixir +{:ok, conn} = ArtefactoryNeo4j.connect( + url: "bolt://localhost:7688", + username: "neo4j", + password: System.get_env("NEO4J_PASSWORD") +) + +:ok = ArtefactoryNeo4j.create_database(conn, "matt_me") +:ok = ArtefactoryNeo4j.write(conn, me_artefact, db: "matt_me") + +:ok = ArtefactoryNeo4j.create_database(conn, "diffo_mob") +:ok = ArtefactoryNeo4j.write(conn, mob_artefact, db: "diffo_mob") +``` + +#### Key modules + +- `ArtefactoryNeo4j` — `connect/1`, `write/3`, `fetch/3`, + `create_database/2`, `drop_database/2`, `stop_database/2`, `start_database/2` +- `ArtefactoryNeo4j.Connection` — supervised GenServer wrapping a Bolty + connection; name-registered, restarts on crash + +#### Local dev — DozerDB via Docker + +```sh +cd artefactory +cp .env.example .env # set NEO4J_PASSWORD +docker compose up -d +``` + +The DozerDB container (`artefactory`) maps: +- `7474` → Neo4j Browser / HTTP (use Chrome or Firefox — Safari blocks the unencrypted Bolt WebSocket) +- `7473` → Neo4j Browser / HTTPS (Safari-compatible, accept the self-signed cert warning) +- `7470` → Bolt direct (`bolt://`) — use this for Bolty and the Neo4j Browser connection URL +- `7471` → Bolt with routing (`neo4j://`) — exposed but not used by artefactory_neo4j + +When connecting via the Neo4j Browser UI, set the connection URL to `bolt://localhost:7470` +(the browser defaults to 7687 — you must override it). + +`docker-compose.yml` and `.env.example` live at the repo root. +Never commit `.env` — it is in `.gitignore`. + +#### Database lifecycle (DozerDB-only features) + +These commands use the `system` database and require DozerDB. +They will fail on plain Neo4j Community: + +```cypher +CREATE DATABASE name IF NOT EXISTS +DROP DATABASE name IF EXISTS +STOP DATABASE name +START DATABASE name +``` + ### REUSE compliance All files carry SPDX headers: ```elixir -# SPDX-FileCopyrightText: 2026 diffo-dev +# SPDX-FileCopyrightText: 2026 2026 artefactory contributors # SPDX-License-Identifier: MIT ``` -Licence text in `LICENSES/MIT.txt`. Copyright holder: `diffo-dev`. +Licence text in `LICENSES/MIT.txt`. Copyright holder: `2026 artefactory contributors `. --- @@ -290,7 +402,9 @@ Licence text in `LICENSES/MIT.txt`. Copyright holder: `diffo-dev`. - **Spelling**: `artefact` not `artifact` — British/Australian spelling, and culturally distinct from build artifacts - **Repo name**: `artefactory` — the universe that holds artefacts and the practice of making them -- **Package names**: `artefact`, `artefact_kino` — the things themselves, not the universe +- **Package names**: + - `artefact`, `artefact_kino` — the things themselves + - `artefactory_*` — artefactory implementations (backend adapters); backend name comes second, in artefactory country - **Licence**: MIT throughout, matching the rest of Diffo - **Elixir version**: `~> 1.16` - **Only runtime dep in `artefact`**: `jason ~> 1.4` @@ -300,11 +414,12 @@ Licence text in `LICENSES/MIT.txt`. Copyright holder: `diffo-dev`. ## What comes next 1. `artefact` tests passing — `mix test` in `artefact/` ✓ -2. Verify `artefact_kino` renders in Livebook — open `notebooks/demo.livemd` -3. Check vis-network CDN import works in Kino.JS context +2. `artefactory_neo4j` — wire up live connection tests against DozerDB ✓ (in progress) +3. Publish `artefactory_neo4j 0.1.0` to hex.pm +4. Publish updated `artefact` (with improved `Artefact.new`) to hex.pm — currently blocked by hex `:timeout` on `--replace`; bump to `0.1.2` and publish fresh +5. Verify `artefact_kino` renders in Livebook — open `notebooks/demo.livemd` +6. Check vis-network CDN import works in Kino.JS context (may need `ctx.importJS` instead of ES module `import`) -4. Push `artefactory` to `github.com/diffo-dev/artefactory` -5. Eventually: build `us_two` as a separate library depending on `artefact` --- diff --git a/LICENSES/MIT.txt b/LICENSES/MIT.txt index ac592fc..025f503 100644 --- a/LICENSES/MIT.txt +++ b/LICENSES/MIT.txt @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 diffo-dev +Copyright (c) 2026 artefactory contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 5db286c..eb47312 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ diff --git a/REUSE.toml b/REUSE.toml index 8cbfc1f..5844023 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -1,25 +1,32 @@ -# SPDX-FileCopyrightText: 2026 diffo-dev +# SPDX-FileCopyrightText: 2026 artefactory contributors # SPDX-License-Identifier: MIT version = 1 # Mix lockfiles — generated, no inline header possible [[annotations]] -path = ["artefact/mix.lock", "artefact_kino/mix.lock"] +path = ["artefact/mix.lock", "artefact_kino/mix.lock", "artefactory_neo4j/mix.lock"] precedence = "aggregate" -SPDX-FileCopyrightText = "2026 diffo-dev" +SPDX-FileCopyrightText = "2026 artefactory contributors " SPDX-License-Identifier = "MIT" # Test fixture data — binary/data files that cannot carry inline SPDX headers [[annotations]] path = ["artefact/test/data/**", "artefact_kino/test/data/**"] precedence = "aggregate" -SPDX-FileCopyrightText = "2026 diffo-dev" +SPDX-FileCopyrightText = "2026 artefactory contributors " SPDX-License-Identifier = "MIT" # Logos [[annotations]] path = ["logos/**"] precedence = "aggregate" -SPDX-FileCopyrightText = "2026 diffo-dev" +SPDX-FileCopyrightText = "2026 artefactory contributors " +SPDX-License-Identifier = "MIT" + +# Dev environment config and generated files — no inline SPDX header possible +[[annotations]] +path = [".env.example", ".reuseignore", "artefactory_neo4j/CHANGELOG.md"] +precedence = "aggregate" +SPDX-FileCopyrightText = "2026 artefactory contributors " SPDX-License-Identifier = "MIT" diff --git a/artefact/.formatter.exs b/artefact/.formatter.exs index 86bf946..a348f64 100644 --- a/artefact/.formatter.exs +++ b/artefact/.formatter.exs @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2026 diffo-dev +# SPDX-FileCopyrightText: 2026 artefactory contributors # SPDX-License-Identifier: MIT [ diff --git a/artefact/CHANGELOG.md b/artefact/CHANGELOG.md new file mode 100644 index 0000000..212cb21 --- /dev/null +++ b/artefact/CHANGELOG.md @@ -0,0 +1,21 @@ + + +# Changelog + +## 0.1.2 — 2026-04-21 + +- Improved `Artefact.new/1` macro — nodes and relationships declared inline with atom keys and keyword options +- `Artefact.Cypher.merge_params/1` — parameterised MERGE returning `{cypher, params}` for driver use +- `Artefact.Cypher.create_params/1` — parameterised CREATE returning `{cypher, params}` +- `Artefact.Binding.find/2` — finds shared nodes between two artefacts by uuid for harmonisation +- `Artefact.harmonise/3` macro — merges two artefacts on shared node bindings; left argument is heartside + +## 0.1.1 — 2026-04-01 + +- `Artefact.Arrows` — lossless round-trip with Arrows JSON (`from_json/2`, `to_json/1`) +- `Artefact.Cypher` — inline `create/1` and `merge/1` Cypher string generation +- `%Artefact{}`, `%Artefact.Graph{}`, `%Artefact.Node{}`, `%Artefact.Relationship{}` structs +- `Artefact.compose/2` — combines two artefacts into one graph diff --git a/artefact/LICENSES/MIT.txt b/artefact/LICENSES/MIT.txt index ac592fc..025f503 100644 --- a/artefact/LICENSES/MIT.txt +++ b/artefact/LICENSES/MIT.txt @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 diffo-dev +Copyright (c) 2026 artefactory contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/artefact/README.md b/artefact/README.md index b745f9e..7251dc6 100644 --- a/artefact/README.md +++ b/artefact/README.md @@ -1,5 +1,5 @@ diff --git a/artefact/lib/artefact.ex b/artefact/lib/artefact.ex index 85eee85..b9fae31 100644 --- a/artefact/lib/artefact.ex +++ b/artefact/lib/artefact.ex @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2026 diffo-dev +# SPDX-FileCopyrightText: 2026 artefactory contributors # SPDX-License-Identifier: MIT defmodule Artefact do diff --git a/artefact/lib/artefact/arrows.ex b/artefact/lib/artefact/arrows.ex index bd8614c..621f04a 100644 --- a/artefact/lib/artefact/arrows.ex +++ b/artefact/lib/artefact/arrows.ex @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2026 diffo-dev +# SPDX-FileCopyrightText: 2026 artefactory contributors # SPDX-License-Identifier: MIT defmodule Artefact.Arrows do diff --git a/artefact/lib/artefact/binding.ex b/artefact/lib/artefact/binding.ex index f100751..0d3ca5e 100644 --- a/artefact/lib/artefact/binding.ex +++ b/artefact/lib/artefact/binding.ex @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2026 diffo-dev +# SPDX-FileCopyrightText: 2026 artefactory contributors # SPDX-License-Identifier: MIT defmodule Artefact.Binding do diff --git a/artefact/lib/artefact/cypher.ex b/artefact/lib/artefact/cypher.ex index a5abb6a..740edbd 100644 --- a/artefact/lib/artefact/cypher.ex +++ b/artefact/lib/artefact/cypher.ex @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2026 diffo-dev +# SPDX-FileCopyrightText: 2026 artefactory contributors # SPDX-License-Identifier: MIT defmodule Artefact.Cypher do diff --git a/artefact/lib/artefact/graph.ex b/artefact/lib/artefact/graph.ex index a9dc400..befbff2 100644 --- a/artefact/lib/artefact/graph.ex +++ b/artefact/lib/artefact/graph.ex @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2026 diffo-dev +# SPDX-FileCopyrightText: 2026 artefactory contributors # SPDX-License-Identifier: MIT defmodule Artefact.Node do diff --git a/artefact/lib/artefact/uuid.ex b/artefact/lib/artefact/uuid.ex index 7649bbd..f700247 100644 --- a/artefact/lib/artefact/uuid.ex +++ b/artefact/lib/artefact/uuid.ex @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2026 diffo-dev +# SPDX-FileCopyrightText: 2026 artefactory contributors # SPDX-License-Identifier: MIT defmodule Artefact.UUID do diff --git a/artefact/mix.exs b/artefact/mix.exs index 2050b40..f194bcf 100644 --- a/artefact/mix.exs +++ b/artefact/mix.exs @@ -1,11 +1,11 @@ -# SPDX-FileCopyrightText: 2026 diffo-dev +# SPDX-FileCopyrightText: 2026 artefactory contributors # SPDX-License-Identifier: MIT defmodule Artefact.MixProject do @moduledoc false use Mix.Project - @version "0.1.1" + @version "0.1.2" @github_url "https://github.com/diffo-dev/artefactory" def project do @@ -37,7 +37,7 @@ defmodule Artefact.MixProject do defp package do [ licenses: ["MIT"], - files: ~w(lib .formatter.exs mix.exs README* LICENSES), + files: ~w(lib .formatter.exs mix.exs README* CHANGELOG* LICENSES), links: %{"GitHub" => @github_url} ] end @@ -46,7 +46,8 @@ defmodule Artefact.MixProject do [ main: "Artefact", source_url: @github_url, - source_ref: "v#{@version}" + source_ref: "v#{@version}", + extras: ["README.md", "CHANGELOG.md"] ] end end diff --git a/artefact/test/artefact_test.exs b/artefact/test/artefact_test.exs index a39d81b..84e5d4f 100644 --- a/artefact/test/artefact_test.exs +++ b/artefact/test/artefact_test.exs @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2026 diffo-dev +# SPDX-FileCopyrightText: 2026 artefactory contributors # SPDX-License-Identifier: MIT defmodule ArtefactTest do diff --git a/artefact/test/test_helper.exs b/artefact/test/test_helper.exs index 58c8929..52a71f8 100644 --- a/artefact/test/test_helper.exs +++ b/artefact/test/test_helper.exs @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2026 diffo-dev +# SPDX-FileCopyrightText: 2026 artefactory contributors # SPDX-License-Identifier: MIT ExUnit.start() diff --git a/artefact_kino/.formatter.exs b/artefact_kino/.formatter.exs index 86bf946..a348f64 100644 --- a/artefact_kino/.formatter.exs +++ b/artefact_kino/.formatter.exs @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2026 diffo-dev +# SPDX-FileCopyrightText: 2026 artefactory contributors # SPDX-License-Identifier: MIT [ diff --git a/artefact_kino/CHANGELOG.md b/artefact_kino/CHANGELOG.md new file mode 100644 index 0000000..feb76d2 --- /dev/null +++ b/artefact_kino/CHANGELOG.md @@ -0,0 +1,19 @@ + + +# Changelog + +## 0.1.2 — 2026-04-21 + +- Compatible with `artefact ~> 0.1.2` +- No functional changes + +## 0.1.1 — 2026-04-01 + +- `ArtefactKino.new/1`, `ArtefactKino.new/2` — Livebook Kino widget for `%Artefact{}` +- Interactive vis-network graph (left panel) with Arrows coordinates preserved +- Cypher fragment display with copy button (right panel) +- Sand Talk aesthetic — dark sand background, ochre nodes and edges +- `:merge` view option showing the harmonised graph diff --git a/artefact_kino/LICENSES/MIT.txt b/artefact_kino/LICENSES/MIT.txt index ac592fc..025f503 100644 --- a/artefact_kino/LICENSES/MIT.txt +++ b/artefact_kino/LICENSES/MIT.txt @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 diffo-dev +Copyright (c) 2026 artefactory contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/artefact_kino/README.md b/artefact_kino/README.md index 5d32dd6..53d2bb6 100644 --- a/artefact_kino/README.md +++ b/artefact_kino/README.md @@ -1,5 +1,5 @@ diff --git a/artefact_kino/artefact_kino.livemd b/artefact_kino/artefact_kino.livemd index b60c26e..e5f395f 100644 --- a/artefact_kino/artefact_kino.livemd +++ b/artefact_kino/artefact_kino.livemd @@ -1,5 +1,5 @@ diff --git a/artefact_kino/lib/artefact_kino.ex b/artefact_kino/lib/artefact_kino.ex index 0dfd4e1..406d43d 100644 --- a/artefact_kino/lib/artefact_kino.ex +++ b/artefact_kino/lib/artefact_kino.ex @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2026 diffo-dev +# SPDX-FileCopyrightText: 2026 artefactory contributors # SPDX-License-Identifier: MIT defmodule ArtefactKino do diff --git a/artefact_kino/mix.exs b/artefact_kino/mix.exs index c248baa..bd4e9fa 100644 --- a/artefact_kino/mix.exs +++ b/artefact_kino/mix.exs @@ -1,11 +1,11 @@ -# SPDX-FileCopyrightText: 2026 diffo-dev +# SPDX-FileCopyrightText: 2026 artefactory contributors # SPDX-License-Identifier: MIT defmodule ArtefactKino.MixProject do @moduledoc false use Mix.Project - @version "0.1.1" + @version "0.1.2" @github_url "https://github.com/diffo-dev/artefactory" def project do @@ -38,7 +38,7 @@ defmodule ArtefactKino.MixProject do defp package do [ licenses: ["MIT"], - files: ~w(lib .formatter.exs mix.exs README* LICENSES), + files: ~w(lib .formatter.exs mix.exs README* CHANGELOG* LICENSES), links: %{"GitHub" => @github_url} ] end @@ -47,7 +47,8 @@ defmodule ArtefactKino.MixProject do [ main: "ArtefactKino", source_url: @github_url, - source_ref: "v#{@version}" + source_ref: "v#{@version}", + extras: ["README.md", "CHANGELOG.md"] ] end end diff --git a/artefact_kino/test/test_helper.exs b/artefact_kino/test/test_helper.exs index 58c8929..52a71f8 100644 --- a/artefact_kino/test/test_helper.exs +++ b/artefact_kino/test/test_helper.exs @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2026 diffo-dev +# SPDX-FileCopyrightText: 2026 artefactory contributors # SPDX-License-Identifier: MIT ExUnit.start() diff --git a/artefactory_neo4j/.formatter.exs b/artefactory_neo4j/.formatter.exs new file mode 100644 index 0000000..a348f64 --- /dev/null +++ b/artefactory_neo4j/.formatter.exs @@ -0,0 +1,6 @@ +# SPDX-FileCopyrightText: 2026 artefactory contributors +# SPDX-License-Identifier: MIT + +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/artefactory_neo4j/CHANGELOG.md b/artefactory_neo4j/CHANGELOG.md new file mode 100644 index 0000000..b1e6958 --- /dev/null +++ b/artefactory_neo4j/CHANGELOG.md @@ -0,0 +1,14 @@ +# Changelog + +## 0.1.0 — 2026-04-21 + +Initial release. + +- `ArtefactoryNeo4j.connect/1` — open a Bolty connection to a Neo4j instance +- `ArtefactoryNeo4j.write/3` — persist an `%Artefact{}` via parameterised MERGE +- `ArtefactoryNeo4j.fetch/3` — retrieve nodes by uuid +- `ArtefactoryNeo4j.create_database/2`, `drop_database/2`, `stop_database/2`, `start_database/2` — DozerDB database lifecycle +- `ArtefactoryNeo4j.Connection` — supervised GenServer wrapping a Bolty connection +- `ArtefactoryNeo4j.Util` — case conversion and validation at the Neo4j boundary +- Automatic `snake_case → camelCase` conversion for property keys at write; reversed on fetch +- Automatic `snake_case → kebab-case` conversion for database names diff --git a/LICENSE b/artefactory_neo4j/LICENSES/MIT.txt similarity index 90% rename from LICENSE rename to artefactory_neo4j/LICENSES/MIT.txt index 7f0f272..025f503 100644 --- a/LICENSE +++ b/artefactory_neo4j/LICENSES/MIT.txt @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 Matthew Graham Beanland +Copyright (c) 2026 artefactory contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/artefactory_neo4j/README.md b/artefactory_neo4j/README.md new file mode 100644 index 0000000..ffb71ab --- /dev/null +++ b/artefactory_neo4j/README.md @@ -0,0 +1,119 @@ + + +# ArtefactoryNeo4j + +Neo4j persistence for [`Artefact`](https://hex.pm/packages/artefact) — read, write, and database lifecycle via [Bolty](https://hex.pm/packages/bolty) and [DozerDB](https://dozerdb.org). + +Part of the [Artefactory](https://github.com/diffo-dev/artefactory) monorepo. + +## What it does + +Persists `%Artefact{}` structs into a Neo4j graph database. Each entity (Me, a Mob, a native You) gets its own named database. A single Bolt connection routes to any of them via the `db:` option. + +Named databases on Neo4j Community Edition require **DozerDB** — a free plugin that adds enterprise multi-database features. See [Local dev](#local-dev) below. + +## Installation + +```elixir +def deps do + [ + {:artefactory_neo4j, "~> 0.1"} + ] +end +``` + +## Usage + +```elixir +{:ok, conn} = ArtefactoryNeo4j.connect( + uri: "bolt://localhost:7470", + auth: [username: "neo4j", password: "password"] +) + +# Create a named database (DozerDB feature) +:ok = ArtefactoryNeo4j.create_database(conn, "matt_me") + +# Write an artefact +:ok = ArtefactoryNeo4j.write(conn, artefact, db: "matt_me") + +# Fetch nodes by uuid +{:ok, rows} = ArtefactoryNeo4j.fetch(conn, uuid, db: "matt_me") +``` + +Database names follow Elixir convention — `snake_case` atom or string. They are converted to Neo4j `kebab-case` automatically at the boundary (`"matt_me"` → `"matt-me"`). Property keys are similarly converted `snake_case → camelCase` on write and back on fetch. + +## Supervised connection + +Use `ArtefactoryNeo4j.Connection` to hold a Bolty connection in a supervision tree: + +```elixir +children = [ + {ArtefactoryNeo4j.Connection, + uri: "bolt://localhost:7470", + auth: [username: "neo4j", password: "password"], + name: :my_conn} +] + +# Retrieve the connection anywhere +conn = ArtefactoryNeo4j.Connection.conn(:my_conn) +``` + +## Database lifecycle + +These commands require DozerDB — they will fail on plain Neo4j Community. + +```elixir +ArtefactoryNeo4j.create_database(conn, "matt_me") +ArtefactoryNeo4j.drop_database(conn, "matt_me") +ArtefactoryNeo4j.stop_database(conn, "matt_me") +ArtefactoryNeo4j.start_database(conn, "matt_me") +``` + +## Neo4j conventions + +`artefactory_neo4j` is the boundary between Elixir country and Neo4j country. +All naming convention translation happens here — callers stay in Elixir convention throughout. + +| Thing | Elixir (caller) | Neo4j | +|--------------------|----------------------|---------------------| +| Database names | `snake_case` string/atom | `kebab-case` string | +| Property keys | `snake_case` string | `camelCase` string | +| Node labels | `PascalCase` string | `PascalCase` string | +| Relationship types | `MACRO_CASE` string | `MACRO_CASE` string | + +Node labels and relationship types are already in Neo4j convention in `%Artefact{}` structs — they pass through unchanged. + +## Local dev + +DozerDB runs as a drop-in replacement for Neo4j Community 5.26.3. The easiest path is Docker — a `docker-compose.yml` is provided at the `artefactory` repo root. + +```sh +cd artefactory +cp .env.example .env # set NEO4J_PASSWORD +docker compose up -d +``` + +Ports: +- `7474` — Neo4j Browser / HTTP (Chrome or Firefox; Safari needs 7473) +- `7473` — Neo4j Browser / HTTPS +- `7470` — Bolt direct (`bolt://`) — use this for connections +- `7471` — Bolt with routing (`neo4j://`) + +When connecting in the Neo4j Browser, set the connection URL to `bolt://localhost:7470` (the browser defaults to 7687). + +## Running tests + +Integration tests require a live DozerDB instance: + +```sh +mix test --include integration +``` + +Excluded by default (`mix test` runs only unit tests). + +## Licence + +MIT — see [LICENSES/MIT.txt](LICENSES/MIT.txt). © 2026 artefactory contributors . diff --git a/artefactory_neo4j/lib/artefactory_neo4j.ex b/artefactory_neo4j/lib/artefactory_neo4j.ex new file mode 100644 index 0000000..1b340ee --- /dev/null +++ b/artefactory_neo4j/lib/artefactory_neo4j.ex @@ -0,0 +1,134 @@ +# SPDX-FileCopyrightText: 2026 artefactory contributors +# SPDX-License-Identifier: MIT + +defmodule ArtefactoryNeo4j do + @moduledoc """ + Neo4j persistence for `%Artefact{}` structs. + + Provides read/write access to a named Neo4j database (via Bolty and DozerDB) + and database lifecycle management — create, drop, stop, start. + + Each entity (Me, Mob, a native You) has its own named database. The `db:` + option on every query routes to the correct database without needing separate + connections. + + ## Usage + + {:ok, conn} = ArtefactoryNeo4j.connect(uri: "bolt://localhost:7688", + auth: [username: "neo4j", password: "password"]) + + :ok = ArtefactoryNeo4j.create_database(conn, "matt_artefactory") + + :ok = ArtefactoryNeo4j.write(conn, artefact, db: "matt_artefactory") + + {:ok, artefact} = ArtefactoryNeo4j.fetch(conn, uuid, db: "matt_artefactory") + """ + + @doc """ + Open a Bolty connection to the Neo4j instance. + """ + def connect(opts) do + Bolty.start_link(opts) + end + + @doc """ + Write an artefact to the given database using parameterised MERGE. + + The `db:` name is Elixir country — `snake_case` atom or string, converted + to Neo4j `kebab-case` automatically. Property keys are converted from + `snake_case` to `camelCase` at the boundary. + """ + def write(conn, %Artefact{} = artefact, opts \\ []) do + db = opts |> Keyword.fetch!(:db) |> ArtefactoryNeo4j.Util.to_database_name() + {cypher, params} = artefact |> neo4j_properties() |> Artefact.Cypher.merge_params() + + case Bolty.query(conn, cypher, params, db: db) do + {:ok, _} -> :ok + {:error, _} = e -> e + end + end + + @doc """ + Fetch nodes matching a uuid from the given database. + + The `db:` name follows Elixir convention — converted to Neo4j `kebab-case` + automatically. Returns `{:ok, rows}` where each row is a map of + `field => %Bolty.Types.Node{}` with property keys in `snake_case`. + """ + def fetch(conn, uuid, opts \\ []) do + db = opts |> Keyword.fetch!(:db) |> ArtefactoryNeo4j.Util.to_database_name() + + case Bolty.query(conn, "MATCH (n {uuid: $uuid}) RETURN n", %{"uuid" => uuid}, db: db) do + {:ok, %Bolty.Response{results: rows}} -> {:ok, Enum.map(rows, &from_neo4j_row/1)} + {:error, _} = e -> e + end + end + + # -- database lifecycle (DozerDB) -- + + @doc """ + Create a named database (DozerDB feature — not available on plain Community). + Name is Elixir country (`snake_case` atom or string) — converted to `kebab-case` automatically. + """ + def create_database(conn, name) do + db = ArtefactoryNeo4j.Util.to_database_name(name) + + case Bolty.query(conn, "CREATE DATABASE `#{db}` IF NOT EXISTS", %{}, db: "system") do + {:ok, _} -> :ok + {:error, _} = e -> e + end + end + + @doc "Drop a named database. Name follows Elixir convention — converted to `kebab-case` automatically." + def drop_database(conn, name) do + db = ArtefactoryNeo4j.Util.to_database_name(name) + + case Bolty.query(conn, "DROP DATABASE `#{db}` IF EXISTS", %{}, db: "system") do + {:ok, _} -> :ok + {:error, _} = e -> e + end + end + + @doc "Stop a named database. Name follows Elixir convention — converted to `kebab-case` automatically." + def stop_database(conn, name) do + db = ArtefactoryNeo4j.Util.to_database_name(name) + + case Bolty.query(conn, "STOP DATABASE `#{db}`", %{}, db: "system") do + {:ok, _} -> :ok + {:error, _} = e -> e + end + end + + @doc "Start a named database. Name follows Elixir convention — converted to `kebab-case` automatically." + def start_database(conn, name) do + db = ArtefactoryNeo4j.Util.to_database_name(name) + + case Bolty.query(conn, "START DATABASE `#{db}`", %{}, db: "system") do + {:ok, _} -> :ok + {:error, _} = e -> e + end + end + + # -- boundary conversion helpers -- + + # Convert all node property keys to camelCase before handing to Cypher generator. + defp neo4j_properties(%Artefact{graph: graph} = artefact) do + nodes = + Enum.map(graph.nodes, fn node -> + %{node | properties: ArtefactoryNeo4j.Util.properties_to_neo4j(node.properties)} + end) + + %{artefact | graph: %{graph | nodes: nodes}} + end + + # Convert a single result row — property keys on returned Bolty nodes back to snake_case. + defp from_neo4j_row(row) do + Map.new(row, fn + {field, %Bolty.Types.Node{properties: props} = node} -> + {field, %{node | properties: ArtefactoryNeo4j.Util.properties_from_neo4j(props)}} + + other -> + other + end) + end +end diff --git a/artefactory_neo4j/lib/artefactory_neo4j/connection.ex b/artefactory_neo4j/lib/artefactory_neo4j/connection.ex new file mode 100644 index 0000000..8f1d08f --- /dev/null +++ b/artefactory_neo4j/lib/artefactory_neo4j/connection.ex @@ -0,0 +1,33 @@ +# SPDX-FileCopyrightText: 2026 artefactory contributors +# SPDX-License-Identifier: MIT + +defmodule ArtefactoryNeo4j.Connection do + @moduledoc """ + A supervised Bolty connection to a Neo4j instance. + + Wrap this in a supervision tree to maintain a persistent, restarting + connection. Each entity (Me, Mob, native You) uses the same connection + to the shared Neo4j instance, routing to its own named database via `db:`. + """ + use GenServer + + def start_link(opts) do + name = Keyword.get(opts, :name, __MODULE__) + GenServer.start_link(__MODULE__, opts, name: name) + end + + def conn(name \\ __MODULE__) do + GenServer.call(name, :conn) + end + + @impl true + def init(opts) do + {:ok, conn} = ArtefactoryNeo4j.connect(opts) + {:ok, %{conn: conn, opts: opts}} + end + + @impl true + def handle_call(:conn, _from, state) do + {:reply, state.conn, state} + end +end diff --git a/artefactory_neo4j/lib/artefactory_neo4j/util.ex b/artefactory_neo4j/lib/artefactory_neo4j/util.ex new file mode 100644 index 0000000..b85bc18 --- /dev/null +++ b/artefactory_neo4j/lib/artefactory_neo4j/util.ex @@ -0,0 +1,164 @@ +# SPDX-FileCopyrightText: 2026 artefactory contributors +# SPDX-License-Identifier: MIT + +defmodule ArtefactoryNeo4j.Util do + @moduledoc """ + Case conversion and validation for the Neo4j boundary. + + Artefactory is Elixir country — names follow Elixir conventions internally. + At the Neo4j boundary, `ArtefactoryNeo4j` adapts to Neo4j conventions: + + | Thing | Elixir (artefactory) | Neo4j | + |--------------------|----------------------|---------------------| + | Property keys | `snake_case` string | `camelCase` string | + | Node labels | `PascalCase` string | `PascalCase` string | + | Relationship types | `MACRO_CASE` string | `MACRO_CASE` string | + | Database names | `snake_case` atom/string | `kebab-case` string | + + Node labels and relationship types are already in Neo4j convention in + `%Artefact{}` structs — they pass through unchanged. + + Property keys and database names are converted at the boundary. + + Adapted from `AshNeo4j.Util` by diffo-dev. + """ + + @doc """ + Converts a `snake_case` string or atom to Neo4j `camelCase` string. + Used for property keys at the write boundary. + + ## Examples + + iex> ArtefactoryNeo4j.Util.to_camel_case("first_name") + "firstName" + iex> ArtefactoryNeo4j.Util.to_camel_case("name") + "name" + iex> ArtefactoryNeo4j.Util.to_camel_case(:first_name) + "firstName" + """ + def to_camel_case(value) when is_atom(value), do: to_camel_case(Atom.to_string(value)) + + def to_camel_case(value) when is_binary(value) do + [head | tail] = String.split(value, "_") + head <> Enum.map_join(tail, "", &String.capitalize/1) + end + + @doc """ + Converts a Neo4j `camelCase` string to Elixir `snake_case` string. + Used for property keys at the read boundary. + + ## Examples + + iex> ArtefactoryNeo4j.Util.to_snake_case("firstName") + "first_name" + iex> ArtefactoryNeo4j.Util.to_snake_case("name") + "name" + """ + def to_snake_case(value) when is_binary(value) do + value + |> String.replace(~r/([A-Z])/, "_\\1") + |> String.downcase() + |> String.trim_leading("_") + end + + @doc """ + Converts a `snake_case` atom or string to a valid Neo4j database name (`kebab-case`). + Neo4j database names may contain ASCII letters, numbers, dots, and dashes — not underscores. + + ## Examples + + iex> ArtefactoryNeo4j.Util.to_database_name(:matt_me) + "matt-me" + iex> ArtefactoryNeo4j.Util.to_database_name("diffo_mob") + "diffo-mob" + iex> ArtefactoryNeo4j.Util.to_database_name("already-fine") + "already-fine" + """ + def to_database_name(value) when is_atom(value), do: to_database_name(Atom.to_string(value)) + + def to_database_name(value) when is_binary(value) do + String.replace(value, "_", "-") + end + + @doc """ + Converts property map keys from Elixir `snake_case` to Neo4j `camelCase`. + Applied to `%Artefact.Node{}` property maps at the write boundary. + + ## Examples + + iex> ArtefactoryNeo4j.Util.properties_to_neo4j(%{"first_name" => "Matt", "age" => 42}) + %{"firstName" => "Matt", "age" => 42} + """ + def properties_to_neo4j(props) when is_map(props) do + Map.new(props, fn {k, v} -> {to_camel_case(k), v} end) + end + + @doc """ + Converts property map keys from Neo4j `camelCase` back to Elixir `snake_case`. + Applied to properties returned by `fetch/3`. + + ## Examples + + iex> ArtefactoryNeo4j.Util.properties_from_neo4j(%{"firstName" => "Matt", "age" => 42}) + %{"first_name" => "Matt", "age" => 42} + """ + def properties_from_neo4j(props) when is_map(props) do + Map.new(props, fn {k, v} -> {to_snake_case(k), v} end) + end + + @doc """ + Returns true if the string is a valid Neo4j property key (`camelCase`, starts lowercase). + + ## Examples + + iex> ArtefactoryNeo4j.Util.valid_property_key?("firstName") + true + iex> ArtefactoryNeo4j.Util.valid_property_key?("first_name") + false + """ + def valid_property_key?(value) when is_binary(value) do + Regex.match?(~r/^[a-z][a-zA-Z0-9]*$/, value) + end + + @doc """ + Returns true if the string is a valid Neo4j node label (`PascalCase`). + + ## Examples + + iex> ArtefactoryNeo4j.Util.valid_label?("Agent") + true + iex> ArtefactoryNeo4j.Util.valid_label?("agent") + false + """ + def valid_label?(value) when is_binary(value) do + Regex.match?(~r/^[A-Z][a-zA-Z0-9]*$/, value) + end + + @doc """ + Returns true if the string is a valid Neo4j relationship type (`MACRO_CASE`). + + ## Examples + + iex> ArtefactoryNeo4j.Util.valid_relationship_type?("US_TWO") + true + iex> ArtefactoryNeo4j.Util.valid_relationship_type?("us_two") + false + """ + def valid_relationship_type?(value) when is_binary(value) do + Regex.match?(~r/^[A-Z]+(_[A-Z]+)*$/, value) + end + + @doc """ + Returns true if the string is a valid Neo4j database name (letters, numbers, dots, dashes). + + ## Examples + + iex> ArtefactoryNeo4j.Util.valid_database_name?("matt-me") + true + iex> ArtefactoryNeo4j.Util.valid_database_name?("matt_me") + false + """ + def valid_database_name?(value) when is_binary(value) do + Regex.match?(~r/^[a-zA-Z0-9][a-zA-Z0-9.\-]*$/, value) + end +end diff --git a/artefactory_neo4j/mix.exs b/artefactory_neo4j/mix.exs new file mode 100644 index 0000000..c77467e --- /dev/null +++ b/artefactory_neo4j/mix.exs @@ -0,0 +1,55 @@ +# SPDX-FileCopyrightText: 2026 artefactory contributors +# SPDX-License-Identifier: MIT + +defmodule ArtefactoryNeo4j.MixProject do + @moduledoc false + use Mix.Project + + @version "0.1.0" + @github_url "https://github.com/diffo-dev/artefactory" + + def project do + [ + app: :artefactory_neo4j, + version: @version, + elixir: "~> 1.16", + start_permanent: Mix.env() == :prod, + deps: deps(), + package: package(), + name: "ArtefactoryNeo4j", + description: + "Neo4j persistence for Artefacts — read, write, and database lifecycle via Bolty and DozerDB", + source_url: @github_url, + docs: docs() + ] + end + + def application do + [extra_applications: [:logger]] + end + + defp deps do + [ + {:artefact, "~> 0.1"}, + {:bolty, "~> 0.0.9"}, + {:ex_doc, "~> 0.37", only: [:dev, :test], runtime: false} + ] + end + + defp package do + [ + licenses: ["MIT"], + files: ~w(lib .formatter.exs mix.exs README* CHANGELOG* LICENSES), + links: %{"GitHub" => @github_url} + ] + end + + defp docs do + [ + main: "ArtefactoryNeo4j", + source_url: @github_url, + source_ref: "v#{@version}", + extras: ["README.md", "CHANGELOG.md"] + ] + end +end diff --git a/artefactory_neo4j/mix.lock b/artefactory_neo4j/mix.lock new file mode 100644 index 0000000..6a21235 --- /dev/null +++ b/artefactory_neo4j/mix.lock @@ -0,0 +1,13 @@ +%{ + "artefact": {:hex, :artefact, "0.1.1", "e46c7d674cc4b7f715c7aa0b4e7186ff10e701c26d77e4c8b3225f703973b841", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "d3ca035e30659c05e2aec55ff661df85c35fc90e6c87ebb95179aa8e0b5f2028"}, + "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_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"}, + "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"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, + "telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"}, +} diff --git a/artefactory_neo4j/test/artefactory_neo4j_test.exs b/artefactory_neo4j/test/artefactory_neo4j_test.exs new file mode 100644 index 0000000..ff5f124 --- /dev/null +++ b/artefactory_neo4j/test/artefactory_neo4j_test.exs @@ -0,0 +1,99 @@ +# SPDX-FileCopyrightText: 2026 artefactory contributors +# SPDX-License-Identifier: MIT + +defmodule ArtefactoryNeo4jTest do + use ExUnit.Case + + @moduletag :integration + + @uri System.get_env("NEO4J_URI", "bolt://localhost:7470") + @user System.get_env("NEO4J_USER", "neo4j") + @pass System.get_env("NEO4J_PASSWORD", "password") + @db "artefactory_neo4j_test" + @db_neo4j "artefactory-neo4j-test" + + @me_uuid "019da897-test-0001-0000-000000000001" + @you_uuid "019da897-test-0001-0000-000000000002" + + setup_all do + {:ok, conn} = ArtefactoryNeo4j.connect(uri: @uri, auth: [username: @user, password: @pass]) + :ok = ArtefactoryNeo4j.create_database(conn, @db) + on_exit(fn -> ArtefactoryNeo4j.drop_database(conn, @db) end) + {:ok, conn: conn} + end + + require Artefact + + test "create_database/2 — database appears in SHOW DATABASES", %{conn: conn} do + {:ok, rows} = Bolty.query(conn, "SHOW DATABASES", %{}, db: "system") + names = Enum.map(rows, & &1["name"]) + assert @db_neo4j in names + end + + test "write/3 — merges Me artefact into the database", %{conn: conn} do + artefact = + Artefact.new( + title: "Me", + base_label: "Me", + nodes: [ + {:n0, [uuid: @me_uuid, labels: ["Agent", "Me"], properties: %{"name" => "Matt"}]} + ], + relationships: [] + ) + + assert :ok = ArtefactoryNeo4j.write(conn, artefact, db: @db) + end + + test "fetch/3 — retrieves a node written by write/3", %{conn: conn} do + artefact = + Artefact.new( + title: "Fetch Test", + base_label: "Me", + nodes: [ + {:n0, [uuid: @me_uuid, labels: ["Agent", "Me"], properties: %{"name" => "Matt"}]} + ], + relationships: [] + ) + + :ok = ArtefactoryNeo4j.write(conn, artefact, db: @db) + {:ok, rows} = ArtefactoryNeo4j.fetch(conn, @me_uuid, db: @db) + assert length(rows) >= 1 + node = hd(rows)["n"] + assert node.properties["uuid"] == @me_uuid + assert node.properties["name"] == "Matt" + end + + test "write/3 — merges UsTwo artefact with relationship", %{conn: conn} do + artefact = + Artefact.new( + title: "UsTwo", + base_label: "UsTwo", + nodes: [ + {:n0, [uuid: @me_uuid, labels: ["Agent", "Me"], properties: %{"name" => "Matt"}]}, + {:n1, [uuid: @you_uuid, labels: ["Agent", "You"], properties: %{"name" => "Claude"}]} + ], + relationships: [ + [from: :n0, to: :n1, type: "US_TWO"] + ] + ) + + assert :ok = ArtefactoryNeo4j.write(conn, artefact, db: @db) + end + + test "write/3 — idempotent: merging twice does not duplicate nodes", %{conn: conn} do + artefact = + Artefact.new( + title: "Idempotent", + base_label: "Me", + nodes: [ + {:n0, [uuid: @me_uuid, labels: ["Agent", "Me"], properties: %{"name" => "Matt"}]} + ], + relationships: [] + ) + + :ok = ArtefactoryNeo4j.write(conn, artefact, db: @db) + :ok = ArtefactoryNeo4j.write(conn, artefact, db: @db) + {:ok, rows} = ArtefactoryNeo4j.fetch(conn, @me_uuid, db: @db) + assert length(rows) == 1 + end +end diff --git a/artefactory_neo4j/test/test_helper.exs b/artefactory_neo4j/test/test_helper.exs new file mode 100644 index 0000000..fb133e9 --- /dev/null +++ b/artefactory_neo4j/test/test_helper.exs @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: 2026 artefactory contributors +# SPDX-License-Identifier: MIT + +# Integration tests require a live DozerDB instance. +# Run with: mix test --include integration +# Default (mix test) skips them. +ExUnit.configure(exclude: [:integration]) +ExUnit.start() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..848ce22 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,28 @@ +# SPDX-FileCopyrightText: 2026 2026 artefactory contributors +# SPDX-License-Identifier: MIT + +services: + artefactory: + image: graphstack/dozerdb:5.26.3.0 + container_name: artefactory + ports: + - "7474:7474" # Browser / HTTP API + - "7473:7473" # Browser / HTTPS API + - "7470:7687" # Bolt direct (bolt://) — no routing + - "7471:7688" # Bolt with routing (neo4j://) + environment: + NEO4J_AUTH: "neo4j/${NEO4J_PASSWORD}" + NEO4J_PLUGINS: "[]" + NEO4J_dbms_security_auth__enabled: "true" + volumes: + - artefactory_data:/data + - artefactory_logs:/logs + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:7474"] + interval: 10s + timeout: 5s + retries: 10 + +volumes: + artefactory_data: + artefactory_logs: