# `Arcana.Graph.GraphStore`
[🔗](https://github.com/georgeguimaraes/arcana/blob/main/lib/arcana/graph/graph_store.ex#L1)

Behaviour and dispatch module for graph storage backends.

Arcana supports swappable graph storage:

- `:ecto` (default) - PostgreSQL via Ecto
- `:memory` - In-memory storage for testing
- Custom module implementing this behaviour

## Configuration

    # config/config.exs

    # Use Ecto/PostgreSQL (default)
    config :arcana, :graph_store, :ecto

    # With options
    config :arcana, :graph_store, {:ecto, repo: MyApp.Repo}

    # Custom module
    config :arcana, :graph_store, MyApp.CustomGraphStore

# `delete_by_chunks`

```elixir
@callback delete_by_chunks(chunk_ids :: [binary()], opts :: keyword()) ::
  :ok | {:error, term()}
```

Deletes all graph data for the given chunk IDs.

Removes mentions referencing these chunks, then sweeps entities the
deletion orphaned. The sweep is scoped to the collections those chunks
belonged to, so deleting in one tenant leaves every other tenant's graph
alone.

Prefer `Arcana.delete/2`, which removes the document and its chunks and
sweeps in one step. Reach for this only when you are deleting chunks
yourself. If the collection is already known, pass its ID as
`:collection_id` so the Ecto backend can still sweep when none of the
chunks have graph mentions.

## The call is not atomic

In the Ecto backend the mention delete and the per-collection sweeps are
separate transactions: the collections to sweep aren't known until the
delete says which entities it touched, and a collection can't be locked
before it has been identified. A crash or a dropped connection between
them commits the delete and skips the rest, leaving entities with no
mentions behind.

Nothing is lost and nothing is wrongly retained: an entity with no
mentions is exactly what a sweep collects, and every sweep is idempotent,
so the next one over that collection finishes the job. Those run from
`Arcana.delete/2` (through `maybe_sweep_orphans/3`), after a
`replace: true` ingest, and from the dashboard's maintenance page, which
also reports the outstanding orphan count.

The memory backend has no such window, since the whole operation is one
`GenServer` call.

Implementations must be idempotent. Post-commit recovery can replay the
same chunk IDs after a backend applied only part of an earlier attempt.

# `delete_by_collection`

```elixir
@callback delete_by_collection(binary(), opts :: keyword()) :: :ok | {:error, term()}
```

Deletes all graph data for a collection.

Removes all entities, relationships, mentions, and communities
associated with the collection.

# `find_entities`

```elixir
@callback find_entities(binary(), opts :: keyword()) :: [map()]
```

Finds all entities in a collection.

# `find_related_entities`

```elixir
@callback find_related_entities(binary(), depth :: pos_integer(), opts :: keyword()) :: [
  map()
]
```

Finds entities related to the given entity within the specified depth.

Enables graph-native traversal operations.

# `get_community`

```elixir
@callback get_community(community_id :: binary(), opts :: keyword()) ::
  {:ok, map()} | {:error, :not_found}
```

Retrieves a single community by ID.

# `get_community_summaries`

```elixir
@callback get_community_summaries(binary(), opts :: keyword()) :: [map()]
```

Retrieves community summaries for a collection.

# `get_entity`

```elixir
@callback get_entity(binary(), opts :: keyword()) :: {:ok, map()} | {:error, :not_found}
```

Retrieves a single entity by ID.

# `get_mentions`

```elixir
@callback get_mentions(binary(), opts :: keyword()) :: [map()]
```

Retrieves mentions for an entity with chunk context.

Returns mentions with associated chunk text for display.

# `get_relationship`

```elixir
@callback get_relationship(relationship_id :: binary(), opts :: keyword()) ::
  {:ok, map()} | {:error, :not_found}
```

Retrieves a single relationship by ID.

# `get_relationships`

```elixir
@callback get_relationships(binary(), opts :: keyword()) :: [map()]
```

Retrieves all relationships for an entity.

Returns relationships where the entity is either source or target.

# `list_communities`

```elixir
@callback list_communities(opts :: keyword()) :: [map()]
```

Lists communities with optional filtering and pagination.

## Options

  * `:collection_id` - Filter by collection (nil for all)
  * `:level` - Filter by hierarchy level
  * `:search` - Search in summary
  * `:limit` - Maximum results (default: 50)
  * `:offset` - Pagination offset (default: 0)

Returns communities with entity counts.

# `list_entities`

```elixir
@callback list_entities(opts :: keyword()) :: [map()]
```

Lists entities with optional filtering and pagination.

## Options

  * `:collection_id` - Filter by collection (nil for all)
  * `:type` - Filter by entity type
  * `:search` - Search in entity name
  * `:limit` - Maximum results (default: 50)
  * `:offset` - Pagination offset (default: 0)

Returns entities with aggregated counts (mention_count, relationship_count).

# `list_relationships`

```elixir
@callback list_relationships(opts :: keyword()) :: [map()]
```

Lists relationships with optional filtering and pagination.

## Options

  * `:collection_id` - Filter by collection (nil for all)
  * `:type` - Filter by relationship type
  * `:search` - Search in entity names or type
  * `:strength` - Filter by strength (:strong, :medium, :weak)
  * `:limit` - Maximum results (default: 50)
  * `:offset` - Pagination offset (default: 0)

Returns relationships with source/target entity names.

# `persist_communities`

```elixir
@callback persist_communities(binary(), [map()], opts :: keyword()) ::
  :ok | {:error, term()}
```

Persists community data for a collection.

# `persist_entities`

```elixir
@callback persist_entities(binary(), [map()], opts :: keyword()) ::
  {:ok, map()} | {:error, term()}
```

Persists entities to the graph store.

Returns a map of entity names to their assigned IDs.

# `persist_mentions`

```elixir
@callback persist_mentions([map()], map(), opts :: keyword()) :: :ok | {:error, term()}
```

Persists entity mentions (links between entities and chunks).

# `persist_relationships`

```elixir
@callback persist_relationships(binary(), [map()], map(), opts :: keyword()) ::
  :ok | {:error, term()}
```

Persists relationships between entities with the chunk that supports them.

# `search`

```elixir
@callback search([String.t()], [binary()] | nil, opts :: keyword()) :: [map()]
```

Searches for chunks related to the given entity names.

Returns scored chunk results.

# `search_by_embedding`

```elixir
@callback search_by_embedding([float()], [binary()] | nil, opts :: keyword()) :: [map()]
```

Searches for entities by embedding similarity.

Returns entities whose description embeddings are most similar to the
query embedding, sorted by similarity descending.

# `sweep_orphans`
*optional* 

```elixir
@callback sweep_orphans(binary(), opts :: keyword()) :: :ok | {:error, term()}
```

Sweeps orphaned graph data in a collection.

Deletes entities in the collection that have no remaining mentions
(their relationships cascade away), and marks communities whose
`entity_ids` overlap the deleted entities as dirty so the next
summarize pass regenerates them.

Intended to run after document deletion or replacement, scoped to the
affected collection.

Optional. A store that doesn't implement it simply doesn't sweep:
`Arcana.delete/2` and the `replace: true` ingest still succeed, and the
collection may keep entities with zero mentions — which is exactly how
both paths behaved before this callback existed.

## Serialization

A sweep must not interleave with a graph build for the same collection:
a build inserts an entity before its mentions, so a sweep landing in
that window would delete an entity that is about to be referenced.

The `:ecto` backend serializes both sides through `with_write_lock/3`
(a transaction-scoped Postgres advisory lock keyed on the collection).
The `:memory` backend uses a per-server, per-collection lock around the
full write sequence. Custom backends must provide the same exclusion.

# `with_write_lock`

```elixir
@callback with_write_lock(binary(), opts :: keyword(), (-&gt; result)) :: result
when result: term()
```

Runs `fun` while holding the collection's graph write lock.

Required for every backend. It serializes graph persistence with document
deletion and replacement, so a chunk cannot gain graph evidence after its
database row has been retired.

The lock is meant to cover DB writes only, never extraction, so callers
keep the wrapped work short.

## Guarantees

The contract is mutual exclusion per collection, nothing more.
Atomicity is best-effort and store-dependent: callers must not assume
that a failure inside `fun` rolls back the writes it already made.

The `:ecto` backend gives both because it takes the advisory lock inside a
transaction. The `:memory` backend provides mutual exclusion but cannot
roll back writes already applied inside the function.

A custom store that wants the full guarantee has to do what the `:ecto`
backend does: hold a per-collection exclusive lock *and* run `fun`
inside a transaction that rolls back on failure, releasing the lock
when the transaction ends. Doing only one of the two is worse than
doing neither, since it reads as if both were covered.

# `backend`

Returns the configured graph store backend.

# `delete_by_chunks`

Deletes all graph data for the given chunk IDs.

The orphan sweep is scoped to the collections the chunks belonged to. It
used to run across every collection in the database, which made this
impossible to call safely in a multi-tenant app.

`Arcana.delete/2` is usually what you want: it removes the document, its
chunks and their graph data together.

`:published_chunk_ids` may be passed when replaying cleanup after the
database rows have already committed away. It records which deleted chunks
were published before deletion so backends can preserve publication-aware
invalidation even though the repo can no longer answer that question.

# `delete_by_collection`

Deletes all graph data for a collection using the configured backend.

# `find_entities`

Finds entities using the configured backend.

# `find_related_entities`

Finds related entities using the configured backend.

# `get_community`

Gets a single community by ID using the configured backend.

# `get_community_summaries`

Gets community summaries using the configured backend.

# `get_entity`

Gets a single entity by ID using the configured backend.

# `get_mentions`

Gets mentions for an entity using the configured backend.

# `get_relationship`

Gets a single relationship by ID using the configured backend.

# `get_relationships`

Gets relationships for an entity using the configured backend.

# `list_communities`

Lists communities using the configured backend.

# `list_entities`

Lists entities using the configured backend.

# `list_relationships`

Lists relationships using the configured backend.

# `maybe_sweep_orphans`

Sweeps orphaned graph data when the graph is enabled for this call.

Shared by `Arcana.delete/2` and the `replace: true` ingest path: both
drop documents whose chunks cascade away, which can strand zero-mention
entities. Returns `:ok` when there is nothing to sweep (no collection,
or the graph is disabled), otherwise the backend's result.

# `persist_communities`

Persists communities using the configured backend.

# `persist_entities`

Persists entities using the configured backend.

# `persist_mentions`

Persists entity mentions using the configured backend.

# `persist_relationships`

Persists relationships using the configured backend.

# `search`

Searches for chunks using the configured backend.

# `search_by_embedding`

Searches for entities by embedding similarity using the configured backend.

# `sweep_orphans`

Sweeps orphaned graph data in a collection using the configured backend.

# `with_write_lock`

Runs `fun` holding the collection's graph write lock on the configured backend.

Every backend must implement `c:with_write_lock/3`. See that callback for
the required serialization and backend-specific rollback guarantees.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
