README.md

# ExpirableStore

Lightweight expirable value store for Elixir with cluster-wide or local scoping.

Perfect for caching OAuth tokens, API keys, and other time-sensitive data that shouldn't be repeatedly refreshed.

## Features

- **Smart caching**: Caches successful results, retries on failure
- **Flexible scoping**: Cluster-wide replication or node-local storage
- **Refresh strategies**: Lazy (on-demand) or eager (background pre-refresh)
- **Concurrency-safe**: Concurrent access protected by `:global.trans/2`
- **Clean DSL**: [Spark](https://github.com/ash-project/spark)-based compile-time configuration
- **Named functions**: Auto-generated functions for each expirable (e.g., `github()`, `github!()`)

## Installation

```elixir
def deps do
  [{:expirable_store, "~> 0.4.2"}]
end
```

## Quick Example

```elixir
defmodule MyApp.Expirables do
  use ExpirableStore

  # Cluster-scoped, lazy refresh
  expirable :github_access_token do
    # Must return {:ok, value, expires_at} or :error
    fetch fn -> GitHubOAuth.fetch_access_token() end
    scope :cluster
    refresh :lazy
  end

  # Local-scoped, lazy refresh
  expirable :datadog_agent_token do
    fetch fn -> DatadogAgent.fetch_local_token() end
    scope :local
    refresh :lazy
  end

  # Cluster-scoped, eager refresh (30 seconds before expiry)
  expirable :fx_rate_usd_krw do
    fetch fn -> FX.fetch_usd_krw() end
    scope :cluster
    refresh {:eager, before_expiry: :timer.seconds(30)}
  end

  # Never expires (cached forever until clear)
  expirable :static_config do
    fetch fn ->
      {:ok, load_config(), :infinity}
    end
  end
end
```

### Usage

```elixir
# Using named functions (recommended)
{:ok, token, expires_at} = MyApp.Expirables.github_access_token()
token = MyApp.Expirables.github_access_token!()

# Or using fetch with name
{:ok, token, expires_at} = MyApp.Expirables.fetch(:github_access_token)
token = MyApp.Expirables.fetch!(:github_access_token)

# Clear stored value
MyApp.Expirables.clear(:github_access_token)
MyApp.Expirables.clear_all()
```

## DSL Options

### `expirable`

Define an expirable value with the following options:

| Option | Values | Default | Description |
|--------|--------|---------|-------------|
| `fetch` | `fn -> {:ok, value, expires_at} \| :error end` | *required* | Function to fetch the value. `expires_at` is Unix timestamp in ms, or `:infinity` |
| `refresh` | `:lazy`, `{:eager, before_expiry: ms}` | `:lazy` | Refresh strategy |
| `scope` | `:cluster`, `:local` | `:cluster` | Scope of the store |

### Refresh Strategies

- **`:lazy`** (default): Refresh on next fetch after expiry
- **`{:eager, before_expiry: ms}`**: Background refresh `ms` milliseconds before expiry

### Never Expiring Values

Return `:infinity` as `expires_at` to cache a value forever (until explicitly cleared):

```elixir
expirable :static_config do
  fetch fn ->
    {:ok, load_config(), :infinity}
  end
end
```

### Scope Options

- **`:cluster`** (default): Replicated across all nodes via `:pg`
- **`:local`**: Node-local only, no replication

## How It Works

### Cluster Scope (`:cluster`)

- **Read replicas**: Each node maintains a local Agent copy for lock-free reads
- **Coordination**: Updates coordinated via `:global.trans/2` to prevent race conditions
- **Replication**: Uses `:pg` for agent group membership across cluster

### Local Scope (`:local`)

- **Node-independent**: Each node maintains its own Agent via `:pg` with node-scoped group
- **Local locking**: Uses `:global.trans/2` with `[node()]` for node-local coordination
- **Use case**: Per-node secrets, local agent tokens, etc.

## When to Use

This library is optimized for **lightweight data** like:
- OAuth tokens, API keys, JWT tokens
- FX rates, configuration values
- Session identifiers, credentials

**NOT recommended for**:
- High-traffic scenarios (frequent reads/writes)
- Dynamic keys (unbounded number of entries)
- Large values

## API Reference

### Define Expirables

```elixir
expirable :name do
  fetch fn ->
    # Must return {:ok, value, expires_at} or :error
    # expires_at is Unix timestamp in milliseconds, or :infinity
    {:ok, value, System.system_time(:millisecond) + ttl_ms}
    # or {:ok, value, :infinity} for never-expiring values
  end

  scope :cluster   # or :local
  refresh :lazy    # or {:eager, before_expiry: ms}
end
```

### Generated Functions

For each `expirable :name`, the following functions are generated:

- `name()` → `{:ok, value, expires_at}` or `:error`
- `name!()` → `value` or raises `RuntimeError`

Additionally, generic functions are available:

- `fetch(name)` → `{:ok, value, expires_at}` or `:error`
- `fetch!(name)` → `value` or raises `RuntimeError`
- `clear(name)` → `:ok`
- `clear_all()` → `:ok`

## Development

```bash
mix test
```