├── test ├── test_helper.exs └── vault_test.exs ├── .formatter.exs ├── CHANGELOG.md ├── .gitignore ├── LICENSE ├── mix.exs ├── .github └── workflows │ └── ci.yml ├── README.md ├── mix.lock └── lib └── vault.ex /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # v0.2.1 2 | 3 | - Use `ProcessTree` library to safely and efficiently traverse process trees. 4 | - Update documentation to better explain the purpose and usage of the library. 5 | 6 | # v0.2.0 7 | 8 | Updates the implementation, extends the documentation, and improves test coverage. 9 | 10 | # v0.1.0 11 | 12 | Initial release 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # If the VM crashes, it generates a dump, let's ignore it too. 14 | erl_crash.dump 15 | 16 | # Also ignore archive artifacts (built via "mix archive.build"). 17 | *.ez 18 | 19 | # Ignore package tarball (built via "mix hex.build"). 20 | context-*.tar 21 | 22 | # Temporary files, for example, from tests. 23 | /tmp/ 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Dima Mikielewicz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Vault.MixProject do 2 | use Mix.Project 3 | 4 | @version "0.2.1" 5 | @source_url "https://github.com/dimamik/vault" 6 | 7 | def project do 8 | [ 9 | app: :vault, 10 | version: @version, 11 | elixir: "~> 1.10", 12 | elixirc_paths: elixirc_paths(Mix.env()), 13 | start_permanent: Mix.env() == :prod, 14 | deps: deps(), 15 | aliases: aliases(), 16 | # Hex 17 | package: package(), 18 | description: 19 | "Vault: a lightweight Elixir library for immutable data storage within a process subtree.", 20 | # Docs 21 | docs: [ 22 | main: "Vault", 23 | api_reference: false, 24 | source_ref: "v#{@version}", 25 | source_url: @source_url, 26 | formatters: ["html"] 27 | ] 28 | ] 29 | end 30 | 31 | def application do 32 | [ 33 | extra_applications: [:logger] 34 | ] 35 | end 36 | 37 | defp elixirc_paths(:test), do: ["lib", "test/support"] 38 | defp elixirc_paths(_env), do: ["lib"] 39 | 40 | defp deps do 41 | [ 42 | {:process_tree, "~> 0.2"}, 43 | {:ex_doc, ">= 0.0.0", only: :dev, runtime: false}, 44 | {:credo, "~> 1.7", only: [:dev, :test], runtime: false} 45 | ] 46 | end 47 | 48 | defp package do 49 | [ 50 | maintainers: ["Dima Mikielewicz"], 51 | licenses: ["MIT"], 52 | links: %{ 53 | Website: "https://dimamik.com", 54 | Changelog: "#{@source_url}/blob/main/CHANGELOG.md", 55 | GitHub: @source_url 56 | }, 57 | files: ~w(lib .formatter.exs mix.exs README* CHANGELOG* LICENSE*) 58 | ] 59 | end 60 | 61 | defp aliases do 62 | [ 63 | release: [ 64 | "cmd git tag v#{@version}", 65 | "cmd git push", 66 | "cmd git push --tags", 67 | "hex.publish --yes" 68 | ] 69 | ] 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Elixir CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - "**" 7 | 8 | # Sets the ENV `MIX_ENV` to `test` for running tests 9 | env: 10 | MIX_ENV: test 11 | 12 | permissions: 13 | contents: read 14 | 15 | concurrency: 16 | group: ${{ github.ref }} # Groups workflows by branch 17 | cancel-in-progress: true # Cancels previous runs when a new one starts 18 | 19 | jobs: 20 | test: 21 | runs-on: ubuntu-latest 22 | name: Test on OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}} 23 | strategy: 24 | matrix: 25 | otp: ['27.2'] 26 | elixir: ['1.18.0'] 27 | steps: 28 | # Step: Setup Elixir + Erlang image as the base. 29 | - name: Set up Elixir 30 | uses: erlef/setup-beam@v1 31 | with: 32 | otp-version: ${{matrix.otp}} 33 | elixir-version: ${{matrix.elixir}} 34 | 35 | # Step: Check out the code. 36 | - name: Checkout code 37 | uses: actions/checkout@v3 38 | 39 | # Step: Define how to cache deps. Restores existing cache if present. 40 | - name: Cache deps 41 | id: cache-deps 42 | uses: actions/cache@v3 43 | env: 44 | cache-name: cache-elixir-deps 45 | with: 46 | path: deps 47 | key: ${{ runner.os }}-mix-${{ env.cache-name }}-${{ hashFiles('**/mix.lock') }} 48 | restore-keys: | 49 | ${{ runner.os }}-mix-${{ env.cache-name }}- 50 | 51 | # Step: Define how to cache the `_build` directory. After the first run, 52 | # this speeds up tests runs a lot. This includes not re-compiling our 53 | # project's downloaded deps every run. 54 | - name: Cache compiled build 55 | id: cache-build 56 | uses: actions/cache@v3 57 | env: 58 | cache-name: cache-compiled-build 59 | with: 60 | path: _build 61 | key: ${{ runner.os }}-mix-${{ env.cache-name }}-${{ hashFiles('**/mix.lock') }} 62 | restore-keys: | 63 | ${{ runner.os }}-mix-${{ env.cache-name }}- 64 | ${{ runner.os }}-mix- 65 | 66 | # Step: Download project dependencies. If unchanged, uses 67 | # the cached version. 68 | - name: Install dependencies 69 | run: mix deps.get --check-locked 70 | 71 | # Step: Compile the project treating any warnings as errors. 72 | # Customize this step if a different behavior is desired. 73 | - name: Compiles without warnings 74 | run: mix compile --warnings-as-errors 75 | 76 | # Step: Check that the checked in code has already been formatted. 77 | # This step fails if something was found unformatted. 78 | # Customize this step as desired. 79 | - name: Check Formatting 80 | run: mix format --check-formatted 81 | 82 | # Step: Check if credo finds any issues. 83 | - name: Run Credo 84 | run: mix credo 85 | 86 | - name: Check for unused dependencies 87 | run: mix deps.unlock --check-unused 88 | 89 | # Step: Execute the tests. 90 | - name: Run tests 91 | run: mix test 92 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Vault 2 | 3 | [![CI](https://github.com/dimamik/vault/actions/workflows/ci.yml/badge.svg)](https://github.com/dimamik/vault/actions/workflows/ci.yml) 4 | [![License](https://img.shields.io/hexpm/l/vault.svg)](https://github.com/dimamik/vault/blob/main/LICENSE) 5 | [![Version](https://img.shields.io/hexpm/v/vault.svg)](https://hex.pm/packages/vault) 6 | [![Hex Docs](https://img.shields.io/badge/documentation-gray.svg)](https://hexdocs.pm/vault) 7 | 8 | 9 | 10 | Vault is a lightweight Elixir library for immutable data storage within a process subtree. 11 | 12 | Due to Elixir's actor model nature, it's common for a process to have global context that is valid for every function call inside of the process and its children. 13 | 14 | For example, this context can include: 15 | 16 | - A user when processing a user's request 17 | - A tenant in a multi-tenant application 18 | - Rate limiting buckets/quotas 19 | - Cache namespaces 20 | - API or client versions 21 | - And many more, depending on your application domain 22 | 23 | --- 24 | 25 | `Vault.init/1` provides you a guarantee that the context can only be defined once per existing process subtree, so you won't override it by accident. This makes it easy to reason about your context origination. 26 | 27 | ## Usage 28 | 29 | ```elixir 30 | # Initialize vault in parent process 31 | Vault.init(current_user: %{id: 1, first_name: "Alice", role: "admin"}) 32 | 33 | # Access data from any descendant process, even these not linked! 34 | spawn(fn -> 35 | Vault.get(:current_user) # => %{id: 1, first_name: "Alice", role: "admin"} 36 | 37 | Vault.init(current_user: :user) # => raises, because the ancestor already has vault initialized 38 | end) 39 | 40 | # Access data from the parent process itself 41 | Vault.get(:current_user) # => %{id: 1, first_name: "Alice", role: "admin"} 42 | ``` 43 | 44 | However, if for some reason you need to split initializations, you can use `Vault.unsafe_merge/1`, but the immutability is no longer guaranteed. 45 | 46 | ## Why Vault? 47 | 48 | - Instead of **property-drilling context data through every function call**, Vault provides access to shared data across your process tree. When used for immutable data - this is a cleaner and more maintainable approach, simplifying cognitive load when reasoning about your code. 49 | - The data is initialized only once and is immutable (unless you explicitly call `unsafe_*` functions). 50 | - The API mirrors Elixir's `Map` module for familiar data access. 51 | 52 | 53 | 54 | ## Credits 55 | 56 | - This library relies on the [`ProcessTree`](https://github.com/jbsf2/process-tree) library by [JB Steadman](https://github.com/jbsf2), which does all the heavy lifting of traversing process trees and propagating data back. You can read more about how ancestors are fetched in [this amazing blog post](https://saltycrackers.dev/posts/how-to-get-the-parent-of-an-elixir-process/) by the library's author. 57 | 58 | ## Installation 59 | 60 | ```elixir 61 | def deps do 62 | [ 63 | {:vault, "~> 0.2.1"} 64 | ] 65 | end 66 | ``` 67 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 3 | "credo": {:hex, :credo, "1.7.12", "9e3c20463de4b5f3f23721527fcaf16722ec815e70ff6c60b86412c695d426c1", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8493d45c656c5427d9c729235b99d498bd133421f3e0a683e5c1b561471291e5"}, 4 | "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, 5 | "ex_doc": {:hex, :ex_doc, "0.38.3", "ddafe36b8e9fe101c093620879f6604f6254861a95133022101c08e75e6c759a", [: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", "ecaa785456a67f63b4e7d7f200e8832fa108279e7eb73fd9928e7e66215a01f9"}, 6 | "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, 7 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 8 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 9 | "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"}, 10 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, 11 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 12 | "process_tree": {:hex, :process_tree, "0.2.1", "4ebcaa96c64a7833467909f49fee28a8e62eed04975613f4c81b4b99424f7e8a", [:mix], [], "hexpm", "68eee6bf0514351aeeda7037f1a6003c0e25de48fe6b7d15a1b0aebb4b35e713"}, 13 | } 14 | -------------------------------------------------------------------------------- /test/vault_test.exs: -------------------------------------------------------------------------------- 1 | defmodule VaultTest do 2 | use ExUnit.Case 3 | doctest Vault 4 | 5 | describe "initialization" do 6 | test "prevents double initialization in same process" do 7 | Vault.init(current_user: "alice") 8 | 9 | assert_raise RuntimeError, 10 | "Vault already initialized in this process", 11 | fn -> Vault.init(role: "admin") end 12 | end 13 | 14 | test "prevents child initialization when ancestor already has vault" do 15 | Vault.init(tenant: "acme") 16 | 17 | Task.async(fn -> 18 | assert_raise RuntimeError, 19 | "Cannot initialize vault: ancestor process already has vault initialized", 20 | fn -> Vault.init(should_fail: true) end 21 | end) 22 | |> Task.await() 23 | end 24 | 25 | test "handles complex nested data structures" do 26 | complex_data = %{ 27 | user: %{ 28 | id: 123, 29 | profile: %{name: "Alice", preferences: %{theme: "dark", lang: "en"}}, 30 | permissions: ["read", "write"] 31 | }, 32 | request: %{id: "req_456", timestamp: DateTime.utc_now()}, 33 | metadata: %{version: "1.0", source: "web"} 34 | } 35 | 36 | Vault.init(complex_data) 37 | 38 | assert Vault.get(:user).id == 123 39 | assert Vault.get(:user).profile.name == "Alice" 40 | assert Vault.get(:user).profile.preferences.theme == "dark" 41 | assert Vault.get(:request).id == "req_456" 42 | end 43 | end 44 | 45 | describe "hierarchical process inheritance" do 46 | test "only leaf nodes can initialize vault initially" do 47 | # Process hierarchy: 48 | # 49 | # ┌───┐ 50 | # │ A │ 51 | # └─┬─┘ 52 | # │ 53 | # ┌────┼────┐ 54 | # │ │ 55 | # ┌─▼─┐ ┌─▼─┐ 56 | # │ B │ │ C │ 57 | # └───┘ └─┬─┘ 58 | # │ 59 | # ┌─▼─┐ 60 | # │ D │ 61 | # └───┘ 62 | # 63 | # Initialization order: B, C successfully. D, A fail due to ancestors with vaults. 64 | 65 | test_pid = self() 66 | 67 | # Process A spawns B and C 68 | {:ok, b_pid} = 69 | Task.start_link(fn -> 70 | receive do 71 | :init -> 72 | assert :ok = Vault.init(node: "B") 73 | end 74 | 75 | receive do 76 | :shutdown -> :ok 77 | end 78 | end) 79 | 80 | {:ok, c_pid} = 81 | Task.start_link(fn -> 82 | # C spawns D 83 | {:ok, d_pid} = 84 | Task.start_link(fn -> 85 | receive do 86 | :try_init -> 87 | assert_raise RuntimeError, 88 | "Cannot initialize vault: ancestor process already has vault initialized", 89 | fn -> 90 | Vault.init(node: "D") 91 | end 92 | end 93 | 94 | receive do 95 | :shutdown -> :ok 96 | end 97 | end) 98 | 99 | send(test_pid, {:d_pid, d_pid}) 100 | 101 | receive do 102 | :init -> 103 | assert :ok = Vault.init(node: "C") 104 | end 105 | 106 | receive do 107 | :shutdown -> 108 | send(d_pid, :shutdown) 109 | :ok 110 | end 111 | end) 112 | 113 | # Get D's PID 114 | assert_receive {:d_pid, d_pid} 115 | 116 | # Test sequence: First init leaf nodes B and C 117 | send(b_pid, :init) 118 | send(c_pid, :init) 119 | 120 | # Now try to init D (should fail because C has vault) 121 | send(d_pid, :try_init) 122 | 123 | # A is the test process itself, so it cannot init when descendants have vaults 124 | # This is expected to succeed because vault doesn't prevent ancestor initialization 125 | # when descendants have vaults - it only prevents child initialization when ancestors have vaults 126 | Vault.init(node: "A") 127 | end 128 | 129 | test "deeply nested process tree access" do 130 | Vault.init( 131 | session_id: "sess_123", 132 | current_user: %{id: 42, role: "admin"}, 133 | tenant: "acme_corp" 134 | ) 135 | 136 | # Level 1 child 137 | Task.async(fn -> 138 | assert Vault.get(:session_id) == "sess_123" 139 | assert Vault.get(:current_user).role == "admin" 140 | 141 | # Level 2 child 142 | Task.async(fn -> 143 | assert Vault.get(:tenant) == "acme_corp" 144 | assert Vault.get(:current_user).id == 42 145 | 146 | # Level 3 child 147 | Task.async(fn -> 148 | assert Vault.get(:session_id) == "sess_123" 149 | end) 150 | |> Task.await() 151 | end) 152 | |> Task.await() 153 | end) 154 | |> Task.await() 155 | end 156 | 157 | test "works with Task.Supervisor spawned processes" do 158 | Vault.init( 159 | correlation_id: "corr_789", 160 | api_version: "v2", 161 | rate_limit: %{requests: 100, window: 3600} 162 | ) 163 | 164 | {:ok, supervisor_pid} = Task.Supervisor.start_link() 165 | 166 | tasks = 167 | for i <- 1..5 do 168 | Task.Supervisor.async(supervisor_pid, fn -> 169 | # Each task can access the vault data 170 | assert Vault.get(:correlation_id) == "corr_789" 171 | assert Vault.get(:api_version) == "v2" 172 | assert Vault.get(:rate_limit).requests == 100 173 | 174 | # Simulate some work 175 | Process.sleep(10) 176 | "task_#{i}_done" 177 | end) 178 | end 179 | 180 | results = Enum.map(tasks, &Task.await/1) 181 | assert length(results) == 5 182 | assert Enum.all?(results, &String.contains?(&1, "task_")) 183 | end 184 | 185 | test "accessible through process ancestry" do 186 | Vault.init(secret: "accessible_through_ancestry") 187 | 188 | # Linked process 189 | Task.async(fn -> 190 | assert Vault.get(:secret) == "accessible_through_ancestry" 191 | end) 192 | |> Task.await() 193 | 194 | # Unlinked process 195 | {:ok, _pid} = 196 | Task.start(fn -> 197 | assert Vault.get(:secret) == "accessible_through_ancestry" 198 | end) 199 | 200 | # Give the unlinked task time to complete 201 | Process.sleep(50) 202 | end 203 | 204 | test "Tasks have access to vault" do 205 | Vault.init(data: "accessible_through_ancestry", current_user: "alice") 206 | 207 | # Task.start now has access through ancestry 208 | {:ok, _pid} = 209 | Task.start(fn -> 210 | assert Vault.get(:data) == "accessible_through_ancestry" 211 | assert Vault.get(:current_user) == "alice" 212 | assert :data in Vault.keys() 213 | assert :current_user in Vault.keys() 214 | 215 | assert Enum.sort(Vault.to_list()) == [ 216 | current_user: "alice", 217 | data: "accessible_through_ancestry" 218 | ] 219 | end) 220 | 221 | # Give the unlinked task time to complete 222 | Process.sleep(50) 223 | 224 | # Linked process still has access 225 | Task.async(fn -> 226 | assert Vault.get(:data) == "accessible_through_ancestry" 227 | assert Vault.get(:current_user) == "alice" 228 | end) 229 | |> Task.await() 230 | end 231 | end 232 | 233 | describe "unsafe operations" do 234 | test "unsafe_put allows modification without initialization" do 235 | # No initialization 236 | assert Vault.get(:emergency) == nil 237 | 238 | Vault.unsafe_put(:emergency, true) 239 | assert Vault.get(:emergency) == true 240 | 241 | Vault.unsafe_put(:debug_mode, %{enabled: true, level: :verbose}) 242 | assert Vault.get(:debug_mode).enabled == true 243 | assert Vault.get(:debug_mode).level == :verbose 244 | end 245 | 246 | test "unsafe_merge combines multiple keys" do 247 | Vault.init(existing: "data", keep_me: true) 248 | 249 | Vault.unsafe_merge(%{ 250 | new_key: "new_value", 251 | another: %{nested: "structure"}, 252 | existing: "overwritten" 253 | }) 254 | 255 | assert Vault.get(:keep_me) == true 256 | assert Vault.get(:new_key) == "new_value" 257 | assert Vault.get(:another).nested == "structure" 258 | assert Vault.get(:existing) == "overwritten" 259 | end 260 | 261 | test "unsafe_update with complex transformations" do 262 | Vault.init( 263 | counters: %{requests: 0, errors: 0}, 264 | user_activity: %{last_seen: nil, actions_count: 5} 265 | ) 266 | 267 | # Increment request counter 268 | Vault.unsafe_update(:counters, %{}, fn counters -> 269 | Map.update(counters, :requests, 1, &(&1 + 1)) 270 | end) 271 | 272 | # Update user activity 273 | now = DateTime.utc_now() 274 | 275 | Vault.unsafe_update(:user_activity, %{}, fn activity -> 276 | activity 277 | |> Map.put(:last_seen, now) 278 | |> Map.update(:actions_count, 0, &(&1 + 1)) 279 | end) 280 | 281 | assert Vault.get(:counters).requests == 1 282 | assert Vault.get(:counters).errors == 0 283 | assert Vault.get(:user_activity).last_seen == now 284 | assert Vault.get(:user_activity).actions_count == 6 285 | end 286 | end 287 | end 288 | -------------------------------------------------------------------------------- /lib/vault.ex: -------------------------------------------------------------------------------- 1 | defmodule Vault do 2 | @external_resource readme = Path.join([__DIR__, "../README.md"]) 3 | 4 | @moduledoc readme 5 | |> File.read!() 6 | |> String.split("") 7 | |> Enum.fetch!(1) 8 | 9 | @vault_key :__vault__ 10 | 11 | @doc """ 12 | Initializes `Vault` in the current process with the given data. 13 | 14 | ## Examples 15 | 16 | iex> Vault.init(current_user: "alice") 17 | :ok 18 | iex> Vault.get(:current_user) 19 | "alice" 20 | 21 | """ 22 | def init(initial_data) do 23 | case get_dictionary_value(self(), @vault_key) do 24 | nil -> 25 | if is_nil(vault()) do 26 | Process.put(@vault_key, Map.new(initial_data)) 27 | :ok 28 | else 29 | raise "Cannot initialize vault: ancestor process already has vault initialized" 30 | end 31 | 32 | _ -> 33 | raise "Vault already initialized in this process" 34 | end 35 | end 36 | 37 | if ProcessTree.OtpRelease.optimized_dictionary_access?() do 38 | defp get_dictionary_value(pid, key) do 39 | case Process.info(pid, {:dictionary, key}) do 40 | {{:dictionary, ^key}, :undefined} -> 41 | nil 42 | 43 | {{:dictionary, ^key}, value} -> 44 | value 45 | 46 | nil -> 47 | nil 48 | end 49 | end 50 | else 51 | defp get_dictionary_value(pid, key) do 52 | case Process.info(pid, :dictionary) do 53 | nil -> 54 | nil 55 | 56 | {:dictionary, dictionary} -> 57 | case List.keyfind(dictionary, key, 0) do 58 | {_key, value} -> 59 | value 60 | 61 | nil -> 62 | nil 63 | end 64 | end 65 | end 66 | end 67 | 68 | @doc """ 69 | Returns the vault map for the current process or its nearest ancestor. 70 | 71 | ## Examples 72 | 73 | iex> Vault.init(current_user: "bob") 74 | :ok 75 | iex> Vault.vault() 76 | %{current_user: "bob"} 77 | 78 | """ 79 | def vault(opts \\ []) do 80 | propagate_vault = Keyword.get(opts, :propagate_vault, :current) 81 | 82 | ProcessTree.get(@vault_key, cache: propagate_vault == :current) 83 | end 84 | 85 | # Reads 86 | 87 | @doc """ 88 | Extracts the value from the current process vault OR from the nearest ancestor 89 | process vault (if any). 90 | 91 | If vault is found in an ancestor process, it's cached in the current process 92 | dictionary for faster subsequent access. 93 | 94 | ## Examples 95 | 96 | iex> Vault.init(current_user: "charlie") 97 | :ok 98 | iex> Vault.fetch!(:current_user) 99 | "charlie" 100 | 101 | See `Map.fetch!/2` for details. 102 | """ 103 | def fetch!(key) do 104 | case vault(propagate_vault: :current) do 105 | nil -> raise KeyError, key: key 106 | vault_map -> Map.fetch!(vault_map, key) 107 | end 108 | end 109 | 110 | @doc """ 111 | Extracts the value from the current process vault OR from the nearest ancestor 112 | process vault (if any). 113 | 114 | If vault is found in an ancestor process, it's cached in the current process 115 | dictionary for faster subsequent access. 116 | 117 | ## Examples 118 | 119 | iex> Vault.init(current_user: "diana") 120 | :ok 121 | iex> Vault.fetch(:current_user) 122 | {:ok, "diana"} 123 | 124 | iex> Vault.fetch(:missing_key) 125 | :error 126 | 127 | See `Map.fetch/2` for details. 128 | """ 129 | def fetch(key) do 130 | case vault(propagate_vault: :current) do 131 | nil -> :error 132 | vault_map -> Map.fetch(vault_map, key) 133 | end 134 | end 135 | 136 | @doc """ 137 | Extracts the value from the current process vault OR from the nearest ancestor 138 | process vault (if any). 139 | 140 | If vault is found in an ancestor process, it's cached in the current process 141 | dictionary for faster subsequent access. 142 | 143 | ## Examples 144 | 145 | iex> Vault.init(current_user: "eve") 146 | :ok 147 | iex> Vault.get(:current_user) 148 | "eve" 149 | 150 | iex> Vault.get(:missing_key, "default") 151 | "default" 152 | 153 | See `Map.get/3` for details. 154 | """ 155 | def get(key, default \\ nil) do 156 | case vault(propagate_vault: :current) do 157 | nil -> default 158 | vault_map -> Map.get(vault_map, key, default) 159 | end 160 | end 161 | 162 | @doc """ 163 | Extracts the value from the current process vault OR from the nearest ancestor 164 | process vault (if any). 165 | 166 | If vault is found in an ancestor process, it's cached in the current process 167 | dictionary for faster subsequent access. 168 | 169 | ## Examples 170 | 171 | iex> Vault.init(current_user: "frank") 172 | :ok 173 | iex> Vault.get_lazy(:current_user, fn -> "default" end) 174 | "frank" 175 | 176 | iex> Vault.get_lazy(:missing_key, fn -> "lazy_default" end) 177 | "lazy_default" 178 | 179 | See `Map.get_lazy/3` for details. 180 | """ 181 | def get_lazy(key, fun) when is_function(fun, 0) do 182 | case vault(propagate_vault: :current) do 183 | nil -> fun.() 184 | vault_map -> Map.get_lazy(vault_map, key, fun) 185 | end 186 | end 187 | 188 | @doc """ 189 | Checks if the key exists in the current process vault OR in the nearest ancestor 190 | process vault (if any). 191 | 192 | If vault is found in an ancestor process, it's cached in the current process 193 | dictionary for faster subsequent access. 194 | 195 | ## Examples 196 | 197 | iex> Vault.init(current_user: "grace") 198 | :ok 199 | iex> Vault.has_key?(:current_user) 200 | true 201 | 202 | iex> Vault.has_key?(:missing_key) 203 | false 204 | 205 | See `Map.has_key?/2` for details. 206 | """ 207 | def has_key?(key) do 208 | case vault(propagate_vault: :current) do 209 | nil -> false 210 | vault_map -> Map.has_key?(vault_map, key) 211 | end 212 | end 213 | 214 | @doc """ 215 | Returns the keys from the current process vault OR from the nearest ancestor process 216 | (if any). 217 | 218 | If vault is found in an ancestor process, it's cached in the current process 219 | dictionary for faster subsequent access. 220 | 221 | ## Examples 222 | 223 | iex> Vault.init(current_user: "henry", role: "admin") 224 | :ok 225 | iex> Vault.keys() |> Enum.sort() 226 | [:current_user, :role] 227 | 228 | See `Map.keys/1` for details. 229 | """ 230 | def keys() do 231 | case vault(propagate_vault: :current) do 232 | nil -> [] 233 | vault_map -> Map.keys(vault_map) 234 | end 235 | end 236 | 237 | @doc """ 238 | Extracts the specified keys and their values from the current process vault OR from the nearest ancestor 239 | process vault (if any). 240 | 241 | If vault is found in an ancestor process, it's cached in the current process 242 | dictionary for faster subsequent access. 243 | 244 | ## Examples 245 | 246 | iex> Vault.init(current_user: "iris", role: "admin", team: "ops") 247 | :ok 248 | iex> Vault.take([:current_user, :role]) 249 | %{current_user: "iris", role: "admin"} 250 | 251 | See `Map.take/2` for details. 252 | """ 253 | def take(keys) when is_list(keys) do 254 | case vault(propagate_vault: :current) do 255 | nil -> %{} 256 | vault_map -> Map.take(vault_map, keys) 257 | end 258 | end 259 | 260 | @doc """ 261 | Returns the vault as a keyword list from the current process OR from the nearest ancestor process 262 | (if any). 263 | 264 | If vault is found in an ancestor process, it's cached in the current process 265 | dictionary for faster subsequent access. 266 | 267 | ## Examples 268 | 269 | iex> Vault.init(current_user: "jack") 270 | :ok 271 | iex> Vault.to_list() 272 | [current_user: "jack"] 273 | 274 | See `Map.to_list/1` for details. 275 | """ 276 | def to_list() do 277 | case vault(propagate_vault: :current) do 278 | nil -> [] 279 | vault_map -> Map.to_list(vault_map) 280 | end 281 | end 282 | 283 | @doc """ 284 | Returns the values from the current process vault OR from the nearest ancestor process 285 | (if any). 286 | 287 | If vault is found in an ancestor process, it's cached in the current process 288 | dictionary for faster subsequent access. 289 | 290 | ## Examples 291 | 292 | iex> Vault.init(current_user: "kate", role: "user") 293 | :ok 294 | iex> Vault.values() |> Enum.sort() 295 | ["kate", "user"] 296 | 297 | See `Map.values/1` for details. 298 | """ 299 | def values() do 300 | case vault(propagate_vault: :current) do 301 | nil -> [] 302 | vault_map -> Map.values(vault_map) 303 | end 304 | end 305 | 306 | # Unsafe updates 307 | 308 | @doc """ 309 | Puts the given key-value pair into the current process vault. 310 | 311 | ## Examples 312 | 313 | iex> Vault.init(current_user: "leo") 314 | :ok 315 | iex> Vault.unsafe_put(:current_user, "updated_leo") 316 | :ok 317 | iex> Vault.get(:current_user) 318 | "updated_leo" 319 | 320 | The updates won't be propagated to processes that already have a vault initialized. 321 | """ 322 | def unsafe_put(key, value) do 323 | current_vault = vault(propagate_vault: :none) || %{} 324 | new_vault = Map.put(current_vault, key, value) 325 | Process.put(@vault_key, new_vault) 326 | :ok 327 | end 328 | 329 | @doc """ 330 | Updates the value for the given key in the current process vault. 331 | 332 | ## Examples 333 | 334 | iex> Vault.init(current_user: "mia") 335 | :ok 336 | iex> Vault.unsafe_update(:current_user, "default", fn user -> String.upcase(user) end) 337 | :ok 338 | iex> Vault.get(:current_user) 339 | "MIA" 340 | 341 | """ 342 | def unsafe_update(key, default, fun) do 343 | current_vault = vault(propagate_vault: :none) || %{} 344 | new_vault = Map.update(current_vault, key, default, fun) 345 | Process.put(@vault_key, new_vault) 346 | :ok 347 | end 348 | 349 | @doc """ 350 | Merges the given data into the current process vault. 351 | 352 | ## Examples 353 | 354 | iex> Vault.init(current_user: "nick") 355 | :ok 356 | iex> Vault.unsafe_merge(role: "admin", team: "engineering") 357 | :ok 358 | iex> Vault.get(:current_user) 359 | "nick" 360 | iex> Vault.get(:role) 361 | "admin" 362 | 363 | """ 364 | def unsafe_merge(new_data) when is_map(new_data) or is_list(new_data) do 365 | current_vault = vault(propagate_vault: :none) || %{} 366 | new_vault = Map.merge(current_vault, Map.new(new_data)) 367 | Process.put(@vault_key, new_vault) 368 | :ok 369 | end 370 | end 371 | --------------------------------------------------------------------------------