---
title: Storage
description: "enpilink's pluggable storage adapters — memory, SQLite, and Postgres — plus registerStorageAdapter for custom backends and optional OpenTelemetry metrics export."
---

Analytics events, captured logs, and config (with its audit trail) are all
persisted through a single pluggable interface: the **`StorageAdapter`**. Pick a
built-in engine with one environment variable, or register your own.

## Choosing an adapter

```bash
ENPILINK_STORAGE=sqlite      # default (dev + production)
ENPILINK_STORAGE=memory      # opt in to an ephemeral, file-less store
ENPILINK_STORAGE=postgres    # external database
```

When `ENPILINK_STORAGE` is unset, enpilink defaults to **`sqlite` in both
development and production** — a durable local `enpilink.db` file. This means
`enpilink dev` now persists your runtime config (e.g. enabling analytics) and
captured analytics/logs **across restarts**. Set `ENPILINK_STORAGE=memory` to opt
back into an ephemeral store. (`enpilink dev --mock` always uses an ephemeral
in-memory store regardless of this setting — the demo seed never touches disk.)

<Note>
The storage engine (`ENPILINK_STORAGE`) and database path (`ENPILINK_DB_PATH`)
are **environment-only** — they're read at boot to *open* the database, so they
can't be edited from the Configuration tab (a value persisted to the DB could
never be honoured on the next boot — a chicken-and-egg). Set them via the
environment as shown here. The same applies to the listening `PORT` (see
[Deploy](/quickstart/deploy)).
</Note>

| Adapter | Best for | Persistence | Extra setup |
|---|---|---|---|
| `sqlite` | default (dev + production) | embedded file on disk (`enpilink.db`) | none (ships with enpilink) |
| `memory` | `--mock`, opt-in ephemeral | none (in-process, lost on exit) | none |
| `postgres` | shared / external DB | your Postgres server | set a connection string |

All adapters implement the same interface (events, logs, config, and the config
audit trail) and return query results most-recent-first, so the Dashboard and
Configuration tab behave identically regardless of engine.

### memory

Zero-dependency ring buffers (default cap 5000 events/logs). Nothing is written
to disk and everything is lost on exit. It is no longer the dev default — opt in
with `ENPILINK_STORAGE=memory` when you want an ephemeral store. It always backs
the [`--mock` demo seed](/guides/observability#demo-data-with-enpilink-dev---mock),
so demo data never touches disk and vanishes on exit.

### sqlite

The **default in both dev and production**: an embedded SQLite database, no
server to run. The file path comes from `ENPILINK_DB_PATH` (default
`./enpilink.db`, relative to the working directory, so each app gets its own
file). The file is gitignored by enpilink's scaffold:

```bash
ENPILINK_STORAGE=sqlite ENPILINK_DB_PATH=/var/lib/enpilink/enpilink.db
```

SQLite ships with enpilink via prebuilt native binaries (macOS-arm64 and
linux-x64), so there's no native build step.

### postgres

For a shared or external database, use the Postgres adapter:

```bash
ENPILINK_STORAGE=postgres
ENPILINK_DB_URL=postgres://user:pass@host:5432/enpilink
```

The connection string is resolved from `ENPILINK_DB_URL`, then `DATABASE_URL`,
and finally the standard `PG*` environment variables (`PGHOST`, `PGUSER`,
`PGPASSWORD`, `PGDATABASE`, …) if neither URL is set. The adapter creates its
tables (`events`, `logs`, `config`, `config_audit`) on first use.

The Postgres driver (`pg`) is **pure JavaScript** and ships with enpilink — no
extra install steps and no native build.

<Note>
The Postgres adapter's config read-then-write is not wrapped in a transaction,
so concurrent writers to the *same* config key are not strictly atomic. This is
fine for single-operator admin config edits. A high-concurrency deployment that
writes config from multiple processes would want a row lock or a single
`INSERT ... RETURNING`.
</Note>

### Firebird (documented adapter slot)

Firebird is a **documented adapter slot**, not a built-in — there is no
`firebird` engine shipped today. To use it, implement the `StorageAdapter`
interface and register it (see below).

## Custom adapters

Register a named factory before the server resolves its adapter, then select it
by name:

```ts
import { registerStorageAdapter } from "enpilink/server";

registerStorageAdapter("my-store", (opts) => new MyStorageAdapter(opts));
```

```bash
ENPILINK_STORAGE=my-store
```

Your adapter must implement the full `StorageAdapter` interface: `init`,
`recordEvent`, `queryEvents`, `appendLog`, `queryLogs`, `getConfig`,
`setConfig` (which must append a `config_audit` row), `allConfig`,
`getConfigAudit`, and `close`. Treat config values as opaque, write an audit row
on every `setConfig`, and return queries most-recent-first.

### End-user auth tracking (optional)

When end-user [authentication](/guides/auth) is enabled with a co-hosted
authorization server, enpilink records a **user** and a **session** on every
successful sign-in. Adapters may implement these **optional** methods (the
built-in `memory` / `sqlite` / `postgres` adapters all do; custom adapters that
omit them simply skip tracking):

| Method | Purpose |
| --- | --- |
| `upsertUser(user)` | Insert-or-update a tracked user keyed by `sub` (`createdAt` sticks; `lastSeenAt` bumps). |
| `recordSession(session)` | Record / refresh a session keyed by its id. |
| `getSession(id)` | Read one session. |
| `listSessions(query?)` | List sessions, most recent first (filter by `sub`, cap with `limit`). |
| `listUsers(query?)` | List tracked users, most recently seen first. |
| `deleteSession(id)` | **Revoke** — delete one session by id (no-op when unknown). |
| `deleteUser(sub)` | Delete a user **and cascade** all their sessions. |

These writes are **best-effort**: enpilink fire-and-forgets them so they never
block or slow a tool call. **Tokens at rest:** a session stores only an opaque
SHA-256 `tokenRef` — never the raw upstream access/refresh token — so there is
no credential at rest to encrypt or leak.

## OpenTelemetry export

Beyond storage, you can **mirror the analytics stream to an OpenTelemetry
collector** as metrics. This is **off by default** — when disabled, no
`@opentelemetry/*` package is imported, no exporter is constructed, and there is
zero network activity.

Enabling requires **both** of:

```bash
ENPILINK_OTEL=1
OTEL_EXPORTER_OTLP_ENDPOINT=https://your-collector:4318
```

(`OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` is preferred if set.) No endpoint is ever
hardcoded — if the endpoint var is unset, OTel stays disabled even with
`ENPILINK_OTEL=1`.

When enabled, each `tool_call` is exported via OTLP/HTTP as three metrics:

| Metric | Type | Attributes |
|---|---|---|
| `enpilink.tool_call.count` | counter | `tool`, `method`, `ok` |
| `enpilink.tool_call.errors` | counter | `tool`, `method` |
| `enpilink.tool_call.duration` | histogram (ms) | `tool`, `method`, `ok` |

Attributes are deliberately low-cardinality. The exporter runs as an additional,
guarded sink alongside storage — recording is fire-and-forget and never blocks
or breaks a tool call, and `--mock` data is never exported. The OpenTelemetry
packages are pure JavaScript and ship with enpilink.

## Related

- [Observability](/guides/observability) — what's recorded and shown
- [Configuration](/guides/configuration) — `storage`/`dbPath` are bootstrap keys; retention caps
- [Admin](/guides/admin) — prod admin uses the SQLite default
