<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Felipe Philipp</title>
    <description>The latest articles on DEV Community by Felipe Philipp (@felipeelias).</description>
    <link>https://dev.to/felipeelias</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F52739%2Fa36f563b-272e-4747-93bb-67390ea592f1.jpg</url>
      <title>DEV Community: Felipe Philipp</title>
      <link>https://dev.to/felipeelias</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/felipeelias"/>
    <language>en</language>
    <item>
      <title>claude-statusline: a configurable status line for Claude Code</title>
      <dc:creator>Felipe Philipp</dc:creator>
      <pubDate>Tue, 17 Mar 2026 15:10:01 +0000</pubDate>
      <link>https://dev.to/felipeelias/claude-statusline-a-configurable-status-line-for-claude-code-4da6</link>
      <guid>https://dev.to/felipeelias/claude-statusline-a-configurable-status-line-for-claude-code-4da6</guid>
      <description>&lt;p&gt;Claude Code lets you customize the status line at the bottom of your terminal. The &lt;a href="https://code.claude.com/docs/en/settings#status-line" rel="noopener noreferrer"&gt;default suggestion&lt;/a&gt; is a bash script, which works but gets clunky fast and difficult to maintain for more complex features.&lt;/p&gt;

&lt;p&gt;There are other tools out there that solve this (more on that below), but none written in Go. I think Go is the right fit here: fast, compiles to a single binary, and cross-platform out of the box, given my setup uses Mac/Linux/Windows. I’m a big fan of &lt;a href="https://starship.rs" rel="noopener noreferrer"&gt;Starship&lt;/a&gt;, and it heavily inspired the design: presets, format strings, per-module config and so on.&lt;/p&gt;

&lt;p&gt;So I built &lt;a href="https://github.com/felipeelias/claude-statusline" rel="noopener noreferrer"&gt;claude-statusline&lt;/a&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmgxpk64v2juwao1mkbby.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmgxpk64v2juwao1mkbby.webp" alt="claude-statusline screenshot" width="800" height="498"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What it shows
&lt;/h2&gt;

&lt;p&gt;The default format displays five modules:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="py"&gt;format&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"$directory | $git_branch | $model | $cost | $context"&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;directory&lt;/code&gt;: current working directory, truncated to the last 3 segments&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;git_branch&lt;/code&gt;: current branch, with an indicator when you’re inside a git worktree&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;model&lt;/code&gt;: which Claude model is running&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cost&lt;/code&gt;: session cost in USD, color-coded by thresholds (yellow at $1, red at $5)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;context&lt;/code&gt;: context window usage as a progress bar with percentage (yellow at 50%, red at 90%)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There are two more modules disabled by default: &lt;code&gt;session_timer&lt;/code&gt; and &lt;code&gt;lines_changed&lt;/code&gt;. You can enable them in the config.&lt;/p&gt;

&lt;h2&gt;
  
  
  Themes
&lt;/h2&gt;

&lt;p&gt;claude-statusline ships with 6 built-in presets:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Preset&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;th&gt;Nerd Font&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;default&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Flat with pipes, standard colors&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;minimal&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Clean spacing, no separators&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pastel-powerline&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Pastel powerline arrows&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tokyo-night&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Dark blues with rounded powerline&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gruvbox-rainbow&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Earthy rainbow powerline&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;catppuccin&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Catppuccin Mocha powerline&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;To switch themes, set the &lt;code&gt;preset&lt;/code&gt; field in your config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="py"&gt;preset&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"tokyo-night"&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Preview all of them with mock data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;claude-statusline themes

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Making it your own
&lt;/h2&gt;

&lt;p&gt;The config lives at &lt;code&gt;~/.config/claude-statusline/config.toml&lt;/code&gt;. You can start from a preset and override individual modules:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="py"&gt;preset&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"catppuccin"&lt;/span&gt;

&lt;span class="nn"&gt;[cost]&lt;/span&gt;
&lt;span class="py"&gt;thresholds&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="err"&gt;{&lt;/span&gt; &lt;span class="py"&gt;above&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;2.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="py"&gt;style&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"yellow"&lt;/span&gt; &lt;span class="err"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="err"&gt;{&lt;/span&gt; &lt;span class="py"&gt;above&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;10.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="py"&gt;style&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"red"&lt;/span&gt; &lt;span class="err"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="nn"&gt;[context]&lt;/span&gt;
&lt;span class="py"&gt;bar_width&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Styles support named colors, hex values, 256-color codes, and attributes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[model]&lt;/span&gt;
&lt;span class="py"&gt;style&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"fg:#11111b bg:#cba6f7 bold"&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rearranging the format string or adding inline styled text also works:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="py"&gt;format&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"$model | $directory | $git_branch | [$cost](dim)"&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Installation
&lt;/h2&gt;

&lt;p&gt;With Homebrew (recommended, keeps updates simple):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;felipeelias/tap/claude-statusline

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or with Go:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;go &lt;span class="nb"&gt;install &lt;/span&gt;github.com/felipeelias/claude-statusline@latest

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then add it to your Claude Code settings (&lt;code&gt;.claude/settings.json&lt;/code&gt; or global):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"statusLine"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"claude-statusline prompt"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Generate a starter config with &lt;code&gt;claude-statusline init&lt;/code&gt;, and use &lt;code&gt;claude-statusline test&lt;/code&gt; to iterate on your config without running Claude Code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Alternatives
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://github.com/hesreallyhim/awesome-claude-code" rel="noopener noreferrer"&gt;awesome-claude-code&lt;/a&gt; list has other options worth checking out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/Owloops/claude-powerline" rel="noopener noreferrer"&gt;claude-powerline&lt;/a&gt;: vim-style powerline with usage tracking and themes&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/Haleclipse/CCometixLine" rel="noopener noreferrer"&gt;CCometixLine&lt;/a&gt;: written in Rust, with interactive TUI configuration&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/hagan/claudia-statusline" rel="noopener noreferrer"&gt;claudia-statusline&lt;/a&gt;: also Rust-based, with persistent stats tracking and cloud sync&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/sirmalloc/ccstatusline" rel="noopener noreferrer"&gt;ccstatusline&lt;/a&gt;: customizable formatter with token usage and metrics&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>claudecode</category>
      <category>statusline</category>
      <category>ai</category>
    </item>
    <item>
      <title>HookLab - Watch your Claude Code hooks in real time</title>
      <dc:creator>Felipe Philipp</dc:creator>
      <pubDate>Sat, 28 Feb 2026 18:51:05 +0000</pubDate>
      <link>https://dev.to/felipeelias/hooklab-watch-your-claude-code-hooks-in-real-time-42n3</link>
      <guid>https://dev.to/felipeelias/hooklab-watch-your-claude-code-hooks-in-real-time-42n3</guid>
      <description>&lt;p&gt;Claude Code recently added &lt;a href="https://docs.claude.com/en/docs/hooks" rel="noopener noreferrer"&gt;HTTP hooks&lt;/a&gt;. Instead of shell scripts, you can point hook events at a URL. I built &lt;a href="https://github.com/felipeelias/hook-lab" rel="noopener noreferrer"&gt;HookLab&lt;/a&gt; to play with that.&lt;/p&gt;

&lt;p&gt;It’s a live dashboard. Every hook event shows up in the browser as it happens: which tools are being called, what arguments they get, what comes back. You can filter by event type, tool, session, and expand any row to inspect the full payload.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Figufba639d35wpluur7g.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Figufba639d35wpluur7g.gif" alt="HookLab dashboard" width="800" height="482"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fsq320syxh7yeozho1ahd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fsq320syxh7yeozho1ahd.png" alt="Expanded hook event" width="800" height="483"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;To run it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;services:
  app:
    image: ghcr.io/felipeelias/hook-lab:latest
    ports:
      - "4000:4000"
    volumes:
      - hook_lab_data:/app/data
    environment:
      SECRET_KEY_BASE: ${SECRET_KEY_BASE}
      DATABASE_PATH: /app/data/hook_lab.db
      PHX_HOST: localhost

volumes:
  hook_lab_data:


export SECRET_KEY_BASE=$(openssl rand -base64 64)
docker compose up -d

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then point your hooks at it. The &lt;a href="https://github.com/felipeelias/hook-lab" rel="noopener noreferrer"&gt;README&lt;/a&gt; has the full &lt;code&gt;settings.json&lt;/code&gt; config you need.&lt;/p&gt;

&lt;p&gt;Built with Phoenix LiveView and SQLite. Next version will let you block or modify hook events based on rules.&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>claude</category>
      <category>hooks</category>
    </item>
    <item>
      <title>You Should Be Versioning Your ~/.claude Config</title>
      <dc:creator>Felipe Philipp</dc:creator>
      <pubDate>Fri, 27 Feb 2026 20:08:56 +0000</pubDate>
      <link>https://dev.to/felipeelias/you-should-be-versioning-your-claude-config-1cmb</link>
      <guid>https://dev.to/felipeelias/you-should-be-versioning-your-claude-config-1cmb</guid>
      <description>&lt;p&gt;If you’ve been using &lt;a href="https://claude.ai/code" rel="noopener noreferrer"&gt;Claude Code&lt;/a&gt; for a while, you’ve probably accumulated a decent amount of configuration: settings, skills, custom agents, CLAUDE.md instructions. Losing all of that would be annoying.&lt;/p&gt;

&lt;p&gt;The fix: &lt;code&gt;git init&lt;/code&gt; inside &lt;code&gt;~/.claude&lt;/code&gt;, with some caveats.&lt;/p&gt;

&lt;h2&gt;
  
  
  What lives in ~/.claude
&lt;/h2&gt;

&lt;p&gt;Based on the &lt;a href="https://code.claude.com/docs/en/settings" rel="noopener noreferrer"&gt;official docs&lt;/a&gt;, here’s what’s worth versioning:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;CLAUDE.md&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;settings.json&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;skills/**/SKILL.md&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;agents/&amp;lt;name&amp;gt;.md&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;commands/&amp;lt;name&amp;gt;.md&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;statusline.sh&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What to ignore
&lt;/h2&gt;

&lt;p&gt;Claude Code generates transient data that you should ignore. Add this to your &lt;code&gt;.gitignore&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Credentials
.credentials.json
credentials.json

# Internal state
.claude.json
.claude.json.backup.*
security_warnings_*.json
stats-cache.json
mcp-needs-auth-cache.json

# Session data
history.jsonl
backups
cache
debug
file-history
paste-cache
session-env
shell-snapshots

# Agent and team state
plans
plugins
tasks
teams
todos

# Telemetry
statsig
telemetry
usage-data

# IDE integration
ide/

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Note about projects
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;projects/&lt;/code&gt; directory contains per-project auto-memory and conversation logs. Claude Code &lt;a href="https://code.claude.com/docs/en/memory" rel="noopener noreferrer"&gt;recently enabled memory per project&lt;/a&gt;, which is worth adding to git. The easiest approach is to ignore the log files instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;projects/**/*.jsonl
projects/**/*.txt

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Quick setup
&lt;/h2&gt;

&lt;p&gt;Remember to push it to GitHub or GitLab, below is an example with GitHub:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cd ~/.claude
git init
git add .gitignore CLAUDE.md settings.json
git add skills/ agents/ commands/ statusline.sh 2&amp;gt;/dev/null
git commit -m "feat: initial claude config"
gh repo create claude-config --private --source=. --push

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That’s it.&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>git</category>
    </item>
    <item>
      <title>Elixir Toolbox - Major Update</title>
      <dc:creator>Felipe Philipp</dc:creator>
      <pubDate>Fri, 27 Feb 2026 10:07:36 +0000</pubDate>
      <link>https://dev.to/felipeelias/elixir-toolbox-major-update-193m</link>
      <guid>https://dev.to/felipeelias/elixir-toolbox-major-update-193m</guid>
      <description>&lt;p&gt;I’ve been working on &lt;a href="https://elixir-toolbox.dev" rel="noopener noreferrer"&gt;Elixir Toolbox&lt;/a&gt; quite a bit lately and wanted to share what’s new:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;More categories: ~150 now, including &lt;a href="https://elixir-toolbox.dev/projects/ai/llm_clients" rel="noopener noreferrer"&gt;AI/LLM&lt;/a&gt; sections (and more)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://elixir-toolbox.dev/api" rel="noopener noreferrer"&gt;JSON API&lt;/a&gt;: the aggregated data is now exposed for anyone to query&lt;/li&gt;
&lt;li&gt;New &lt;a href="https://elixir-toolbox.dev/trending" rel="noopener noreferrer"&gt;trending&lt;/a&gt; page, showing packages by recent downloads&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;llms.txt&lt;/code&gt; and &lt;code&gt;llms-full.txt&lt;/code&gt; endpoints so LLMs can read the catalog: try asking your AI agent to “find me the best Elixir package for X using elixir-toolbox.dev”&lt;/li&gt;
&lt;li&gt;Complete UI refresh, using &lt;a href="https://daisyui.com" rel="noopener noreferrer"&gt;DaisyUI&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitLab support&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is a passion project. I use it to keep my Elixir skills sharp, and hopefully it helps others too. Check it out!&lt;/p&gt;

</description>
      <category>elixir</category>
    </item>
    <item>
      <title>Perfect Claude Code Notifications Setup with Tailscale and ntfy</title>
      <dc:creator>Felipe Philipp</dc:creator>
      <pubDate>Thu, 26 Feb 2026 08:08:54 +0000</pubDate>
      <link>https://dev.to/felipeelias/perfect-claude-code-notifications-setup-with-tailscale-and-ntfy-1ii1</link>
      <guid>https://dev.to/felipeelias/perfect-claude-code-notifications-setup-with-tailscale-and-ntfy-1ii1</guid>
      <description>&lt;p&gt;If you’re like me and have been hooked &lt;a href="https://petesena.medium.com/how-to-run-claude-code-from-your-iphone-using-tailscale-termius-and-tmux-2e16d0e5f68b" rel="noopener noreferrer"&gt;into running Claude Code on your phone&lt;/a&gt;, running several &lt;a href="https://x.com/bcherny/status/2007179833990885678" rel="noopener noreferrer"&gt;sessions in parallel like Boris&lt;/a&gt;, you may have noticed that it is easy to lose track of what is going on on all those sessions. You may go away for a sec, distracted by &lt;a href="https://www.youtube.com/watch?v=OqPxaKs8xrk" rel="noopener noreferrer"&gt;Minecraft parkour videos&lt;/a&gt; and forget that Claude is waiting for your input.&lt;/p&gt;

&lt;h2&gt;
  
  
  Idea
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://code.claude.com/docs" rel="noopener noreferrer"&gt;Claude Code&lt;/a&gt; comes with a &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;notification hook&lt;/a&gt;. Some terminals support it natively (&lt;a href="https://iterm2.com" rel="noopener noreferrer"&gt;iTerm2&lt;/a&gt;, &lt;a href="https://sw.kovidgoyal.net/kitty/" rel="noopener noreferrer"&gt;Kitty&lt;/a&gt;, &lt;a href="https://ghostty.org" rel="noopener noreferrer"&gt;Ghostty&lt;/a&gt;) but most don’t, and even when they do, it’s a system notification which is easy to miss if you step away.&lt;/p&gt;

&lt;p&gt;The idea is to get a phone notification when Claude Code needs your input. I considered a few options, and I ended up choosing &lt;a href="https://ntfy.sh" rel="noopener noreferrer"&gt;ntfy&lt;/a&gt; as the notification provider.&lt;/p&gt;

&lt;p&gt;To make sure that everything stays private, I decided to host ntfy on my machine and use &lt;a href="https://tailscale.com" rel="noopener noreferrer"&gt;Tailscale&lt;/a&gt; as my private network.&lt;/p&gt;

&lt;p&gt;I was also tired of dealing with bash scripts. I kept running into compatibility issues between Mac, Linux and Windows, so I built a small tool to solve that (but you can still use bash).&lt;/p&gt;

&lt;h2&gt;
  
  
  Requirements
&lt;/h2&gt;

&lt;p&gt;The only thing you need is a Tailscale account and &lt;a href="https://www.docker.com" rel="noopener noreferrer"&gt;Docker&lt;/a&gt; for that. If you want to go with bash, it helps to have &lt;a href="https://jqlang.org/" rel="noopener noreferrer"&gt;&lt;code&gt;jq&lt;/code&gt;&lt;/a&gt; installed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 0: Project Structure
&lt;/h2&gt;

&lt;p&gt;Here are the files you’ll need:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;my-infra/
├── .env
├── compose.yml
└── config/
    └── ntfy.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h2&gt;
  
  
  Step 1: Configure &lt;a href="https://tailscale.com/kb/1068/acl-tags" rel="noopener noreferrer"&gt;Tailscale ACL&lt;/a&gt;
&lt;/h2&gt;

&lt;p&gt;Go to the &lt;a href="https://login.tailscale.com/admin/acls" rel="noopener noreferrer"&gt;ACL editor&lt;/a&gt; and add a &lt;code&gt;tag:container&lt;/code&gt; tag:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"tagOwners": {
  "tag:container": ["autogroup:admin"]
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h2&gt;
  
  
  Step 2: Create an &lt;a href="https://tailscale.com/kb/1215/oauth-clients" rel="noopener noreferrer"&gt;OAuth Credential&lt;/a&gt;
&lt;/h2&gt;

&lt;p&gt;Go to &lt;a href="https://login.tailscale.com/admin/settings/trust-credentials" rel="noopener noreferrer"&gt;Trust &amp;amp; Credentials&lt;/a&gt; to generate a new OAuth credential.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Click &lt;strong&gt;Credential&lt;/strong&gt; → &lt;strong&gt;OAuth&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Grant &lt;code&gt;auth_keys&lt;/code&gt; scope with &lt;strong&gt;write&lt;/strong&gt; permission&lt;/li&gt;
&lt;li&gt;Select tag &lt;code&gt;tag:container&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Copy the client secret (&lt;code&gt;tskey-client-...&lt;/code&gt;)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;OAuth works better because the regular auth keys expire in 1–90 days. OAuth client credentials don’t expire and the container re-authenticates automatically on restart.&lt;/p&gt;

&lt;p&gt;Now add the OAuth key to your &lt;code&gt;.env&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TS_AUTHKEY=...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h2&gt;
  
  
  Step 3: Docker Compose
&lt;/h2&gt;

&lt;p&gt;Your compose will look like below. It uses the &lt;a href="https://hub.docker.com/r/tailscale/tailscale" rel="noopener noreferrer"&gt;&lt;code&gt;tailscale/tailscale&lt;/code&gt;&lt;/a&gt; and &lt;a href="https://hub.docker.com/r/binwiederhier/ntfy" rel="noopener noreferrer"&gt;&lt;code&gt;binwiederhier/ntfy&lt;/code&gt;&lt;/a&gt; images and relies on &lt;a href="https://tailscale.com/docs/features/containers/docker" rel="noopener noreferrer"&gt;Tailscale sidecar pattern&lt;/a&gt; where it &lt;strong&gt;exposes your Docker containers as machines&lt;/strong&gt; in the tailnet. This is really useful because you can reach the Docker container by name directly, the sidecar will proxy the request, handle HTTPS, etc.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;name: my-infra

services:
  ts-ntfy:
    image: tailscale/tailscale:latest
    container_name: ts-ntfy
    hostname: ntfy
    restart: unless-stopped
    environment:
      - TS_AUTHKEY=${TS_AUTHKEY}?ephemeral=false
      - TS_EXTRA_ARGS=--advertise-tags=tag:container --reset
      - TS_SERVE_CONFIG=/config/ntfy.json
      - TS_STATE_DIR=/var/lib/tailscale
      - TS_USERSPACE=false
    volumes:
      - ts-ntfy-state:/var/lib/tailscale
      - ./config:/config
    devices:
      - /dev/net/tun:/dev/net/tun
    cap_add:
      - net_admin

  ntfy:
    image: binwiederhier/ntfy
    container_name: ntfy
    restart: unless-stopped
    command: serve
    environment:
      NTFY_BASE_URL: "https://ntfy.&amp;lt;your-tailnet&amp;gt;.ts.net"
      NTFY_UPSTREAM_BASE_URL: "https://ntfy.sh"
    network_mode: service:ts-ntfy
    depends_on:
      - ts-ntfy

volumes:
  ts-ntfy-state:
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Note the &lt;a href="https://docs.ntfy.sh/config/#upstream-ntfysh" rel="noopener noreferrer"&gt;&lt;strong&gt;&lt;code&gt;NTFY_UPSTREAM_BASE_URL&lt;/code&gt;&lt;/strong&gt;&lt;/a&gt; setting. This forwards push notifications through ntfy.sh’s Firebase/APNs infrastructure for instant mobile delivery. Without it, notifications can be delayed by minutes or hours.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 4: &lt;a href="https://tailscale.com/kb/1312/serve" rel="noopener noreferrer"&gt;Tailscale Serve&lt;/a&gt; Config
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;config/ntfy.json&lt;/code&gt; — this tells Tailscale to proxy HTTPS to ntfy’s port 80:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{
  "TCP": {
    "443": {
      "HTTPS": true
    }
  },
  "Web": {
    "${TS_CERT_DOMAIN}:443": {
      "Handlers": {
        "/": {
          "Proxy": "http://127.0.0.1:80"
        }
      }
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h2&gt;
  
  
  Step 5: Start It
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;docker compose up -d
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Give it ~15 seconds for the TLS certificate to be provisioned. ntfy is now available at &lt;code&gt;https://ntfy.&amp;lt;your-tailnet&amp;gt;.ts.net&lt;/code&gt; from any device on your tailnet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tip:&lt;/strong&gt; Your tailnet name (the &lt;code&gt;taila2944f&lt;/code&gt; part) can be changed to something more readable in &lt;a href="https://login.tailscale.com/admin/dns" rel="noopener noreferrer"&gt;DNS settings&lt;/a&gt;. Also make sure that “HTTPS Certificates” are enabled.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 6: Subscribe on Your Phone
&lt;/h2&gt;

&lt;p&gt;You need to install the ntfy app, available on &lt;a href="https://play.google.com/store/apps/details?id=io.heckel.ntfy" rel="noopener noreferrer"&gt;Google Play&lt;/a&gt; and the &lt;a href="https://apps.apple.com/us/app/ntfy/id1625396347" rel="noopener noreferrer"&gt;App Store&lt;/a&gt;. Once installed you need to subscribe to a topic with your server URL. For example:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Add &lt;code&gt;claude-code&lt;/code&gt; as the topic&lt;/li&gt;
&lt;li&gt;Choose the custom server: &lt;code&gt;https://ntfy.&amp;lt;your-tailnet&amp;gt;.ts.net&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You can make a quick test with:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;curl -s -H "Title: Test" -d "Hello from the terminal!" "https://ntfy.&amp;lt;your-tailnet&amp;gt;.ts.net/claude-code"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h2&gt;
  
  
  Step 7: Claude Code Hook
&lt;/h2&gt;

&lt;p&gt;Now wire up Claude Code to send notifications through ntfy. You have a few options:&lt;/p&gt;
&lt;h3&gt;
  
  
  Option 1: claude-notifier
&lt;/h3&gt;

&lt;p&gt;This is the tool I built to solve that: &lt;a href="https://github.com/felipeelias/claude-notifier" rel="noopener noreferrer"&gt;claude-notifier&lt;/a&gt;. It handles multiple notification channels, sending to ntfy but also to native system notifications (in Mac, via &lt;a href="https://github.com/julienXX/terminal-notifier" rel="noopener noreferrer"&gt;&lt;code&gt;terminal-notifier&lt;/code&gt;&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;

&lt;/p&gt;
&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/felipeelias" rel="noopener noreferrer"&gt;
        felipeelias
      &lt;/a&gt; / &lt;a href="https://github.com/felipeelias/claude-notifier" rel="noopener noreferrer"&gt;
        claude-notifier
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Notification dispatcher for Claude Code hooks
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;claude-notifier&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;&lt;a href="https://github.com/felipeelias/claude-notifier/actions/workflows/ci.yml" rel="noopener noreferrer"&gt;&lt;img src="https://github.com/felipeelias/claude-notifier/actions/workflows/ci.yml/badge.svg" alt="CI"&gt;&lt;/a&gt;
&lt;a href="https://github.com/felipeelias/claude-notifier/blob/main/go.mod" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/b5f7f8e80ced5d224e9f310e17d49cea0c81fa6d1adff0455482d6325e4f28ae/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f676f2d6d6f642f676f2d76657273696f6e2f66656c697065656c6961732f636c617564652d6e6f746966696572" alt="Go"&gt;&lt;/a&gt;
&lt;a href="https://github.com/felipeelias/claude-notifier/blob/main/LICENSE" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/fdf2982b9f5d7489dcf44570e714e3a15fce6253e0cc6b5aa61a075aac2ff71b/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4c6963656e73652d4d49542d79656c6c6f772e737667" alt="License: MIT"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Notification dispatcher for &lt;a href="https://docs.anthropic.com/en/docs/claude-code" rel="nofollow noopener noreferrer"&gt;Claude Code&lt;/a&gt; hooks. Reads JSON from stdin, fans out to all configured notification channels concurrently. Single static binary, compiled-in plugins, TOML configuration.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Why&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;Claude Code has &lt;a href="https://docs.anthropic.com/en/docs/claude-code/hooks" rel="nofollow noopener noreferrer"&gt;notification hooks&lt;/a&gt; that run a shell command when the agent needs your attention. Most people write a bash script that curls ntfy or sends a desktop notification. That works fine for one channel on one machine.&lt;/p&gt;
&lt;p&gt;It gets annoying when you want notifications on your phone &lt;em&gt;and&lt;/em&gt; your desktop, or you move to a different OS and have to rewrite the script, or you want high priority for errors but low priority for routine updates.&lt;/p&gt;
&lt;p&gt;claude-notifier is a single binary that handles all of that:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Sends to multiple channels from one hook (ntfy, desktop, Slack, etc.)&lt;/li&gt;
&lt;li&gt;Same binary and config file across Linux, macOS, and Windows&lt;/li&gt;
&lt;li&gt;Always exits 0 so it never breaks your hook&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;claude-notifier init&lt;/code&gt;, edit the…&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/felipeelias/claude-notifier" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;




&lt;p&gt;Install it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;brew install felipeelias/tap/claude-notifier
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Generate the config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;claude-notifier init
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This creates &lt;code&gt;~/.config/claude-notifier/config.toml&lt;/code&gt;. Point it to your ntfy server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[[notifiers.ntfy]]
url = "https://ntfy.&amp;lt;your-tailnet&amp;gt;.ts.net/claude-code"
title = "Claude Code ({{.Project}})"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then add the hook to &lt;code&gt;~/.claude/settings.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{
  "hooks": {
    "Notification": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "claude-notifier"
          }
        ]
      }
    ]
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same binary and config on every machine. Run &lt;code&gt;claude-notifier test&lt;/code&gt; to verify it works.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option 2: Bash script
&lt;/h3&gt;

&lt;p&gt;You can still go with a bash script if you want. Create &lt;code&gt;~/.claude/hooks/notify.sh&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;#!/usr/bin/env bash
set -euo pipefail

# Convert backslashes for Windows path compatibility
INPUT=$(cat | tr '\\' '/')
PROJECT=$(printf '%s' "$INPUT" | jq -r '.cwd // empty' | xargs basename 2&amp;gt;/dev/null || echo "")
HOOK_TITLE=$(printf '%s' "$INPUT" | jq -r '.title // empty')
MESSAGE=$(printf '%s' "$INPUT" | jq -r '.message // "Done"')

if [-n "$HOOK_TITLE"]; then
  TITLE="$HOOK_TITLE"
elif [-n "$PROJECT"]; then
  TITLE="Claude Code ($PROJECT)"
else
  TITLE="Claude Code"
fi

curl -s \
  -H "Title: $TITLE" \
  -d "$MESSAGE" \
  "${NTFY_URL}/claude-code" &amp;gt; /dev/null 2&amp;gt;&amp;amp;1 || true
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Make it executable (&lt;code&gt;chmod +x ~/.claude/hooks/notify.sh&lt;/code&gt;) and add to &lt;code&gt;~/.claude/settings.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{
  "env": {
    "NTFY_URL": "https://ntfy.&amp;lt;your-tailnet&amp;gt;.ts.net"
  },
  "hooks": {
    "Notification": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "~/.claude/hooks/notify.sh"
          }
        ]
      }
    ]
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This requires &lt;code&gt;jq&lt;/code&gt; (&lt;code&gt;brew install jq&lt;/code&gt;, &lt;code&gt;apt install jq&lt;/code&gt;, or &lt;code&gt;winget install jqlang.jq&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting all together
&lt;/h2&gt;

&lt;p&gt;If all is working you should see this:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F599r9t0u014l8cs7ot9e.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F599r9t0u014l8cs7ot9e.webp" alt="ntfy notification on phone"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Troubleshooting
&lt;/h2&gt;

&lt;p&gt;If the notification says “New message”, make sure that all devices (including your phone) are on the same Tailscale network. If they are and you’re still not getting notifications, you can always ask Claude to help you debug it.&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>tailscale</category>
      <category>ntfy</category>
      <category>docker</category>
    </item>
    <item>
      <title>Denormalized indexing with elasticsearch-rails</title>
      <dc:creator>Felipe Philipp</dc:creator>
      <pubDate>Sat, 06 Jan 2018 11:15:47 +0000</pubDate>
      <link>https://dev.to/felipeelias/denormalized-indexing-with-elasticsearch-rails-1bfl</link>
      <guid>https://dev.to/felipeelias/denormalized-indexing-with-elasticsearch-rails-1bfl</guid>
      <description>&lt;p&gt;One of the biggest advantages of using &lt;a href="https://www.elastic.co/products/elasticsearch" rel="noopener noreferrer"&gt;Elasticsearch&lt;/a&gt; it is because it's fast even if you have very complex documents with many attributes coming from different models.&lt;/p&gt;

&lt;p&gt;Achieving that requires you to &lt;a href="https://en.wikipedia.org/wiki/Denormalization" rel="noopener noreferrer"&gt;denormalize&lt;/a&gt; those models into a single &lt;code&gt;index&lt;/code&gt; and it's your responsibility as a developer to keep it consistent.&lt;/p&gt;

&lt;p&gt;That reveals some interesting challenges, let's take a look.&lt;/p&gt;

&lt;h2&gt;
  
  
  Blog in 5 minutes
&lt;/h2&gt;

&lt;p&gt;Imagine we have a simple has_many/belongs_to relationship between &lt;code&gt;Posts&lt;/code&gt; and &lt;code&gt;Authors&lt;/code&gt;. Our end goal is to be able to search by &lt;em&gt;posts from a specific author by its name&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Assuming that you have a basic Rails application with &lt;a href="https://github.com/elastic/elasticsearch-rails" rel="noopener noreferrer"&gt;elasticsearch-rails&lt;/a&gt; installed and &lt;a href="https://www.elastic.co/products/elasticsearch" rel="noopener noreferrer"&gt;Elasticsearch&lt;/a&gt; running, our models will look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# db/migrate/...&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CreateModels&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ActiveRecord&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Migration&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;5.1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;change&lt;/span&gt;
    &lt;span class="n"&gt;create_table&lt;/span&gt; &lt;span class="ss"&gt;:authors&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
      &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt; &lt;span class="ss"&gt;:name&lt;/span&gt;
      &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt; &lt;span class="ss"&gt;:email&lt;/span&gt;
      &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timestamps&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;

    &lt;span class="n"&gt;create_table&lt;/span&gt; &lt;span class="ss"&gt;:posts&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
      &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt; &lt;span class="ss"&gt;:title&lt;/span&gt;
      &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt; &lt;span class="ss"&gt;:body&lt;/span&gt;
      &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;datetime&lt;/span&gt; &lt;span class="ss"&gt;:published_at&lt;/span&gt;
      &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;references&lt;/span&gt; &lt;span class="ss"&gt;:author&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;foreign_key: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;
      &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timestamps&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="c1"&gt;# models/author.rb&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Author&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationRecord&lt;/span&gt;
  &lt;span class="n"&gt;has_many&lt;/span&gt; &lt;span class="ss"&gt;:posts&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="c1"&gt;# models/post.rb&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Post&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationRecord&lt;/span&gt;
  &lt;span class="kp"&gt;include&lt;/span&gt; &lt;span class="no"&gt;Elasticsearch&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Model&lt;/span&gt;
  &lt;span class="kp"&gt;include&lt;/span&gt; &lt;span class="no"&gt;Elasticsearch&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Model&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Callbacks&lt;/span&gt;

  &lt;span class="n"&gt;belongs_to&lt;/span&gt; &lt;span class="ss"&gt;:author&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;Post&lt;/code&gt; model will be our entry point to manage changes in the index. I'm not going to get into details of how the &lt;a href="https://github.com/elastic/elasticsearch-rails" rel="noopener noreferrer"&gt;elasticsearch-rails&lt;/a&gt; gem works, you can check its documentation on the &lt;a href="https://github.com/elastic/elasticsearch-rails" rel="noopener noreferrer"&gt;github repository&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Assuming you imported all posts and users, you can perform full-text search with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'example'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;records&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That will let you search for every attribute in the &lt;code&gt;Post&lt;/code&gt; model, but not by &lt;code&gt;author&lt;/code&gt; names.&lt;/p&gt;

&lt;h2&gt;
  
  
  Extending
&lt;/h2&gt;

&lt;p&gt;Now for the fun part. Let's add &lt;code&gt;author&lt;/code&gt; information in the same index as the &lt;code&gt;posts&lt;/code&gt;. This will help us achieve our goal of searching by &lt;code&gt;author&lt;/code&gt; name.&lt;/p&gt;

&lt;p&gt;You'll need to define a custom mapping and a how to index it by overriding &lt;code&gt;#as_indexed_json&lt;/code&gt; method:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Post&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationRecord&lt;/span&gt;
  &lt;span class="c1"&gt;# ... snipped&lt;/span&gt;

  &lt;span class="n"&gt;mapping&lt;/span&gt; &lt;span class="ss"&gt;dynamic: :strict&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;indexes&lt;/span&gt; &lt;span class="ss"&gt;:id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;           &lt;span class="ss"&gt;type: :long&lt;/span&gt;
    &lt;span class="n"&gt;indexes&lt;/span&gt; &lt;span class="ss"&gt;:title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="ss"&gt;type: :text&lt;/span&gt;
    &lt;span class="n"&gt;indexes&lt;/span&gt; &lt;span class="ss"&gt;:body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;         &lt;span class="ss"&gt;type: :text&lt;/span&gt;
    &lt;span class="n"&gt;indexes&lt;/span&gt; &lt;span class="ss"&gt;:published_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;type: :date&lt;/span&gt;
    &lt;span class="n"&gt;indexes&lt;/span&gt; &lt;span class="ss"&gt;:created_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="ss"&gt;type: :date&lt;/span&gt;
    &lt;span class="n"&gt;indexes&lt;/span&gt; &lt;span class="ss"&gt;:updated_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="ss"&gt;type: :date&lt;/span&gt;
    &lt;span class="n"&gt;indexes&lt;/span&gt; &lt;span class="ss"&gt;:author&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
      &lt;span class="n"&gt;indexes&lt;/span&gt; &lt;span class="ss"&gt;:id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="ss"&gt;type: :long&lt;/span&gt;
      &lt;span class="n"&gt;indexes&lt;/span&gt; &lt;span class="ss"&gt;:name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;type: :text&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;as_indexed_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt;
    &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;as_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;merge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="ss"&gt;only: &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:published_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:created_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:updated_at&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="ss"&gt;include: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;author: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;only: &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:name&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After changing this, you must &lt;strong&gt;recreate&lt;/strong&gt; the index and &lt;strong&gt;re-import&lt;/strong&gt; the data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;__elasticsearch__&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_index!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;force: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="no"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;import&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Then, there is one bug 🐞
&lt;/h2&gt;

&lt;p&gt;If you use your application for a while, you'll notice that if you change the &lt;code&gt;author&lt;/code&gt; of a post, this change won't be reflected in Elasticsearch.&lt;/p&gt;

&lt;p&gt;After some debugging, it turns out that &lt;a href="https://github.com/elastic/elasticsearch-rails" rel="noopener noreferrer"&gt;elasticsearch-rails&lt;/a&gt; gem only indexes the attributes that changed via &lt;a href="http://api.rubyonrails.org/classes/ActiveModel/Dirty.html" rel="noopener noreferrer"&gt;ActiveModel::Dirty&lt;/a&gt; module. That doesn't work for our case since &lt;code&gt;author&lt;/code&gt; is not an attribute of a &lt;code&gt;post&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Simply put, when you modify the &lt;code&gt;author&lt;/code&gt; of a &lt;code&gt;post&lt;/code&gt;, the attribute that changes is the &lt;code&gt;author_id&lt;/code&gt;. After you save the &lt;code&gt;post&lt;/code&gt;, the gem compares which attributes changed against the hash returned by &lt;code&gt;#as_indexed_json&lt;/code&gt;. Since our changes are now represented as an &lt;code&gt;author&lt;/code&gt; hash, the gem can't find the &lt;code&gt;author_id&lt;/code&gt; there.&lt;/p&gt;

&lt;p&gt;There are a couple of ways to solve this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Drop the &lt;code&gt;Elasticsearch::Model::Callbacks&lt;/code&gt; module and then handle the indexing logic yourself&lt;/li&gt;
&lt;li&gt;Force a change by adding the &lt;code&gt;author&lt;/code&gt; key as a change whenever the &lt;code&gt;author_id&lt;/code&gt; changes&lt;/li&gt;
&lt;li&gt;Ignoring all changes completely&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I choose to go with solution #2, which looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Post&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationRecord&lt;/span&gt;
  &lt;span class="c1"&gt;# ... snipped&lt;/span&gt;

  &lt;span class="n"&gt;before_save&lt;/span&gt; &lt;span class="ss"&gt;:force_index&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;force_index&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;changes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'author_id'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
      &lt;span class="kp"&gt;attr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="ss"&gt;:@__changed_model_attributes&lt;/span&gt;
      &lt;span class="n"&gt;old_changes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;__elasticsearch__&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;instance_variable_get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kp"&gt;attr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="n"&gt;__elasticsearch__&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;instance_variable_set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kp"&gt;attr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;old_changes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;merge!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'author'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's a hack, it changes the internals of the &lt;a href="https://github.com/elastic/elasticsearch-rails" rel="noopener noreferrer"&gt;elasticsearch-rails&lt;/a&gt; gem and I'm not very happy with the solution. I went this way to keep the functionality of indexing only changed attributes however, this can get pretty cumbersome to maintain.&lt;/p&gt;

&lt;p&gt;If you don't care about this optimization, you can go with #3 and &lt;strong&gt;always&lt;/strong&gt; force the index by clearing the &lt;code&gt;@__changed_model_attributes&lt;/code&gt; instance variable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;force_index&lt;/span&gt;
  &lt;span class="n"&gt;__elasticsearch__&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;instance_variable_set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:@__changed_model_attributes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kp"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With either approach, if you change the &lt;code&gt;author&lt;/code&gt; the changes will be reflected in Elasticsearch.&lt;/p&gt;

&lt;h2&gt;
  
  
  And then, there are two bugs 🐞🐞
&lt;/h2&gt;

&lt;p&gt;After the hint on the previous bug, one would notice that changing the author's name won't reflect on &lt;a href="https://www.elastic.co/products/elasticsearch" rel="noopener noreferrer"&gt;Elasticsearch&lt;/a&gt; either! That's because &lt;code&gt;Author&lt;/code&gt; model doesn't know anything about indexing itself the &lt;code&gt;Post&lt;/code&gt; index.&lt;/p&gt;

&lt;p&gt;This is where keeping the consistency on the index gets tricky. There are numerous ways of solving this, each of them with its own drawbacks. To keep things simple I'm going to suggest one solution that works well and doesn't use any other dependency other than &lt;a href="https://www.elastic.co/products/elasticsearch" rel="noopener noreferrer"&gt;Elasticsearch&lt;/a&gt; itself 🎉🎉🎉.&lt;/p&gt;

&lt;p&gt;We'll use &lt;code&gt;#update_by_query&lt;/code&gt; feature which as the name suggests, lets you update various documents that match a query. It has some cool features like being able to work &lt;em&gt;asynchronously&lt;/em&gt;, updating documents at its own pace without &lt;em&gt;overloading the cluster&lt;/em&gt; and handling &lt;em&gt;conflicts&lt;/em&gt;. Check out the &lt;a href="https://www.elastic.co/guide/en/elasticsearch/reference/5.6/docs-update-by-query.htm" rel="noopener noreferrer"&gt;documentation here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Let's take advantage of that to update all posts that belong to a specific &lt;code&gt;author&lt;/code&gt; in the background:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# models/author.rb&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Author&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationRecord&lt;/span&gt;
  &lt;span class="n"&gt;after_commit&lt;/span&gt; &lt;span class="ss"&gt;:update_relations&lt;/span&gt;

  &lt;span class="kp"&gt;private&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;update_relations&lt;/span&gt;
    &lt;span class="no"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update_authors&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="c1"&gt;# models/post.rb&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Post&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationRecord&lt;/span&gt;
  &lt;span class="c1"&gt;# ... snipped&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nc"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update_authors&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;author&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt;
    &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:index&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||=&lt;/span&gt; &lt;span class="n"&gt;index_name&lt;/span&gt;
    &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:type&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="o"&gt;||=&lt;/span&gt; &lt;span class="n"&gt;document_type&lt;/span&gt;
    &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:wait_for_completion&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||=&lt;/span&gt; &lt;span class="kp"&gt;false&lt;/span&gt;

    &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:body&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="ss"&gt;conflicts: :proceed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;query: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="ss"&gt;match: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="s1"&gt;'author.id'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;author&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="ss"&gt;script: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="ss"&gt;lang:   :painless&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="ss"&gt;source: &lt;/span&gt;&lt;span class="s2"&gt;"ctx._source.author.name = params.author.name"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="ss"&gt;params: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;author: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;name: &lt;/span&gt;&lt;span class="n"&gt;author&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;name&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;__elasticsearch__&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update_by_query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The code is quite self-explanatory. Any changes in the &lt;code&gt;Author&lt;/code&gt; model will trigger an &lt;code&gt;#update_by_query&lt;/code&gt; which performs an update for all posts that match the query:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="ss"&gt;query: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="ss"&gt;match: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="s1"&gt;'author.id'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;author&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For each match, it will execute the &lt;em&gt;scripted update&lt;/em&gt; defined, which simply sets the &lt;code&gt;author&lt;/code&gt; name to the one specified in the &lt;code&gt;params&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="ss"&gt;script: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="ss"&gt;lang:   :painless&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;source: &lt;/span&gt;&lt;span class="s2"&gt;"ctx._source.author.name = params.author.name"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;params: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;author: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;name: &lt;/span&gt;&lt;span class="n"&gt;author&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;name&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You may want to optimize the &lt;code&gt;#update_relations&lt;/code&gt; method to only call &lt;code&gt;#update_authors&lt;/code&gt; when necessary. Using &lt;code&gt;params&lt;/code&gt; let you easily include more attributes in the future and also avoids potential security issues brought by concatenating strings in the &lt;code&gt;source&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Setting &lt;code&gt;wait_for_completion&lt;/code&gt; to &lt;code&gt;false&lt;/code&gt; will tell &lt;a href="https://www.elastic.co/products/elasticsearch" rel="noopener noreferrer"&gt;Elasticsearch&lt;/a&gt; to perform the update asynchronously. This is good if there is a potential case of an &lt;code&gt;author&lt;/code&gt; having tons of posts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Thinking about conflicts
&lt;/h2&gt;

&lt;p&gt;You may have noticed that I set &lt;code&gt;conflicts: :proceed&lt;/code&gt; in the updated body. This is to handle a couple of scenarios:&lt;/p&gt;

&lt;h3&gt;
  
  
  The post is updated
&lt;/h3&gt;

&lt;p&gt;Imagine the case where we update an author's name that has one bazillion posts. That will take a while... There is a chance that any of the author's posts will be updated by somebody else in the meantime.&lt;/p&gt;

&lt;p&gt;Before running an &lt;code&gt;#update_by_query&lt;/code&gt;, &lt;a href="https://www.elastic.co/products/elasticsearch" rel="noopener noreferrer"&gt;Elasticsearch&lt;/a&gt; takes a snapshot of the index and uses the internal versioning scheme to identify such conflicts. If a &lt;code&gt;post&lt;/code&gt; is updated after the time when update was "queued" and before it was "run", the &lt;code&gt;post&lt;/code&gt; will have a new version, so the &lt;code&gt;#update_by_query&lt;/code&gt; will fail for that &lt;code&gt;post&lt;/code&gt;. In this scenario, we'd like to &lt;strong&gt;skip&lt;/strong&gt; such conflicts and proceed.&lt;/p&gt;

&lt;p&gt;This means that the &lt;em&gt;last update wins&lt;/em&gt; and we have the guarantee that the post will have the latest value for the author's name.&lt;/p&gt;

&lt;h3&gt;
  
  
  Multiple updates to the same author
&lt;/h3&gt;

&lt;p&gt;If somebody updates the &lt;code&gt;author&lt;/code&gt; once, then immediately regrets this decision and updates it again to something else, there is a chance that the first update will still be running (considering bazillion of posts). If that's the case, the first update will encounter conflicts, ignore them and move on.&lt;/p&gt;

&lt;p&gt;In theory, the second update will always win because it will come after the first one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://en.wikipedia.org/wiki/Denormalization" rel="noopener noreferrer"&gt;Denormalizing&lt;/a&gt; data can help you take advantage of &lt;a href="https://www.elastic.co/products/elasticsearch" rel="noopener noreferrer"&gt;Elasticsearch&lt;/a&gt; fast querying features, but it has a cost of having to handle concurrent updates to multiple models, which reveals some pretty hard to debug issues and inconsistency.&lt;/p&gt;

&lt;p&gt;Note that is a very simple scenario and probably you won't need &lt;a href="https://www.elastic.co/products/elasticsearch" rel="noopener noreferrer"&gt;Elasticsearch&lt;/a&gt; if you don't have anything other than that. However, the biggest advantage comes when you have to index many different models in the same document and when doing joins in the database becomes prohibitive.&lt;/p&gt;




&lt;p&gt;Originally published at &lt;a href="https://medium.com/@felipeelias/denormalized-indexing-with-elasticsearch-rails-1c4549d7d393" rel="noopener noreferrer"&gt;Medium&lt;/a&gt; on December 27, 2017.&lt;/p&gt;

</description>
      <category>ruby</category>
      <category>rails</category>
      <category>elasticsearch</category>
    </item>
  </channel>
</rss>
