# How we use ETS as a live code-graph database in Synapse MCP

We have built/building [Synapse MCP](https://synapse-mcp.dev) — a tool that gives AI agents a persistent map of your codebase — and one design question came up early: where does the call graph live?

After a lot of experimentation — SQLite queries, Postgres, SQLite, in-process caches — we kept coming back to ETS. The reason is simple: when an agent asks "who calls Parser.parse/2?", you want the answer in under a millisecond, with no connection pool, no query planner, and no network hop. Nothing else on the BEAM gives you that.

We landed on a GenServer that owns a dozen named tables, each typed to its access pattern:

```elixir
@tables [
  {:synapse_chunks,       :set},  # chunk_id → Chunk
  {:synapse_index,        :bag},  # {repo_id, file_path} → [chunk_id]
  {:synapse_edges,        :bag},  # from_chunk_id → edge_map
  {:synapse_edge_targets, :bag},  # to_chunk_id → caller_map  ← the one
  {:synapse_file_index,   :set},  # {repo_id, rel_path} → {mtime, size}
]
```

The `:bag` type earns its keep. synapse_edge_targets inverts every edge by its target chunk — "who calls this?" is a single lookup returning every caller, no join needed:

```elixir
def get_callers(to_chunk_id) do
  safe_lookup(:synapse_edge_targets, to_chunk_id)
end
```
Reads bypass the GenServer entirely — direct :ets.lookup/2 on public named tables. The GenServer only serialises writes. Durability goes through an async SQLiteWriter cast so reads never wait on disk.

The tradeoff: ETS is per-node. On restart, init/1 fires {:continue, :warm_ets} — an unlinked task rehydrates from SQLite per-repo, flipping each to :ready as it finishes. Warm repos serve queries while cold ones load in the background.

SQLite is just the backing store. The hot path never touches it.



---

Created by: spasm-myelixlabs
Date: September 22, 2026
URL: https://elixirdrops.net/d/N9Cs6tRB
